diff --git a/.editorconfig b/.editorconfig index d8d42bbc99..278fd7b94b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,14 +8,23 @@ end_of_line = lf charset = utf-8 insert_final_newline = true trim_trailing_whitespace = true +indent_style = space +max_line_length = 90 [**.py] -indent_style = tab +indent_size = 4 + +[**.yaml] +indent_size = 2 [src/octoprint/static/js/app/**.js] -indent_style = space indent_size = 4 -[.travis.yml] -indent_style = space +[src/octoprint/static/js/login/**.js] +indent_size = 4 + +[src/octoprint/static/js/recovery/**.js] +indent_size = 4 + +[src/octoprint/static/less/octoprint.less] indent_size = 2 diff --git a/.eslintrc.yaml b/.eslintrc.yaml new file mode 100644 index 0000000000..c34fe23396 --- /dev/null +++ b/.eslintrc.yaml @@ -0,0 +1,40 @@ +overrides: + - files: + - "src/octoprint/static/js/app/**" + env: + browser: true + jquery: true + parserOptions: + ecmaVersion: 5 + rules: + es5/no-arrow-functions: error + es5/no-binary-and-octal-literals: error + es5/no-block-scoping: error + es5/no-classes: error + es5/no-computed-properties: error + es5/no-default-parameters: error + es5/no-destructuring: error + es5/no-exponentiation-operator: error + es5/no-for-of: error + es5/no-generators: error + es5/no-modules: error + es5/no-object-super: error + es5/no-rest-parameters: error + es5/no-shorthand-properties: error + es5/no-spread: error + es5/no-template-literals: error + es5/no-typeof-symbol: error + es5/no-unicode-code-point-escape: error + es5/no-unicode-regex: error + - files: + - "tests/cypress/**" + env: + node: true + es6: true + cypress/globals: true + extends: + - "plugin:cypress/recommended" + +plugins: + - eslint-plugin-es5 + - cypress diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..005b4260fa --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,16 @@ +# refs to ignore during git blame, use with --ignore-revs-file + +# Switch to black formatting +1f93b2355a2816918e11a55e61b42010d15a6720 +5f2a95d154650366078b0d387e5b01427c4340c0 + +# Switch to prettier formatting +6b44909f21ee39237b7504cd31ffa6cf28275c33 + +# EOF/trailing whitespace check +#cae6730ea87da18bd6ebfeaf3e1e9cd354390939 + +# dict_to_literal, set_to_literal, not_in, flake8 fixes +#af60fd642832736966104ae71d852d745397c749 +#986312b6ae7eeadcaf620cc99d53b23794be83b6 +#b5be8f3eaec18ae2a88110a3a16c552931d493dd diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..5311f0fa2b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# someone from the docker-maintainers team will be requested for review +# whenever the trigger_docker workflow is changed in a Pull Request +.github/workflows/trigger_docker.yml @OctoPrint/docker-maintainers diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..c55ac50cf2 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,5 @@ +# These are supported funding model platforms + +github: [foosel] +patreon: foosel +custom: https://octoprint.org/support-octoprint/ diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 00bd36a44c..0000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,108 +0,0 @@ -READ THE FOLLOWING FIRST: - -If not already done, please read the "guidelines for contributing" -aka the Contribution Guidelines that are linked ^-- just up there -in the big yellow box. - -Also read the FAQ: https://github.com/foosel/OctoPrint/wiki/FAQ. - -This is a bug and feature tracker, please only use it to report bugs -or request features within OctoPrint (not OctoPi, not any OctoPrint -plugins and not unofficial OctoPrint versions). - -Do not seek support here ("I need help with ...", "I have a -question ..."), that belongs on the mailing list or the G+ community -(both linked in the "guidelines for contributing" linked above, read -them!), NOT here. - -Mark requests with a "[Request]" prefix in the title please. For bug -reports fully fill out the bug reporting template (if you don't know -where to find some information - it's all described in the Contribution -Guidelines linked up there in the big yellow box). - -When reporting a bug do NOT delete ANY lines from the template but -those enclosed in [ and ] - and those please DO delete, they are -only provided for your information and removing them makes your -ticket more readable :) - -Make sure any bug you want to report is still present with the CURRENT -OctoPrint version and that it does not vanish when you start OctoPrint -in safe mode - how to do that is also explained in the Contribution -Guidelines linked up there in the big yellow box. - -Thank you! - -(Before submitting your ticket, please delete this text up to and -including the line too - it's only here for you, we already know it -by heart ;)) - ----- - -#### What were you doing? - -[Please be as specific as possible here. The maintainers will need to -reproduce your issue in order to fix it and that is not possible if they -don't know what you did to get it to happen in the first place. - -Ideally provide exact steps to follow in order to reproduce your problem: - -1. ... -2. ... -3. ... - -If you encountered a problem with specific files of any sorts, make sure -to also include a link to a file with which to reproduce the problem.] - -#### What did you expect to happen? - -#### What happened instead? - -#### Did the same happen when running OctoPrint in safe mode? - -[Try to reproduce your problem in safe mode. You can find information -on how to enable safe mode in the Contribution Guidelines.] - -#### Version of OctoPrint - -[Can be found in the lower left corner of the web interface. ALWAYS INCLUDE.] - -#### Operating System running OctoPrint - -[OctoPi, Linux, Windows, MacOS, something else? With version please. -OctoPi's version can be found in /etc/octopi_version] - -#### Printer model & used firmware incl. version - -[If applicable, always include if unsure.] - -#### Browser and Version of Browser, Operating System running Browser - -[If applicable, always include if unsure.] - -#### Link to octoprint.log - -[On gist.github.com or pastebin.com. ALWAYS INCLUDE and never truncate. -The Contribution Guidelines tell you where to find that.] - -#### Link to contents of terminal tab or serial.log - -[On gist.github.com or pastebin.com. If applicable, always include if unsure or -reporting communication issues. Never truncate. - -serial.log is usually not written due to performance reasons and must be -enabled explicitly. Provide at the very least the FULL contents of your -terminal tab at the time of the bug occurrence, even if you do not have -a serial.log (which the Contribution Guidelines tell you where to find).] - -#### Link to contents of Javascript console in the browser - -[On gist.github.com or pastebin.com or alternatively a screenshot. If applicable - -always include if unsure or reporting UI issues. - -The Contribution Guidelines tell you where to find that.] - -#### Screenshot(s)/video(s) showing the problem: - -[If applicable. Always include if unsure or reporting UI issues.] - -I have read the FAQ. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..7b78ced9ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,130 @@ +name: 🐛 Report a bug +description: Create a bug report to help improve OctoPrint +body: + - type: markdown + attributes: + value: > + **Thank you for wanting to report a bug in OctoPrint!** + + + If this is the first time you are doing this, please take a few moments to read + through the [Contribution Guidelines](https://github.com/OctoPrint/OctoPrint/blob/master/CONTRIBUTING.md). + Also check out [the FAQ](https://faq.octoprint.org) if your + problem is maybe already covered there. + + + You are about to report a bug in **OctoPrint**. Do not proceed if your issues + occurs with OctoPi, any third party OctoPrint plugins, unofficial or outdated + OctoPrint versions. If you are unsure of the difference between OctoPrint and + OctoPi, [read this FAQ entry](https://faq.octoprint.org/octoprint-vs-octopi). + + + Do also not seek support here ("I need help with ...", "I have a + question ...", "Can someone walk me through ..."), that belongs into the + [community forum at community.octoprint.org](https://community.octoprint.org) or on the [Discord server](https://discord.octoprint.org). + + + And finally, make sure any bug you want to report is still present with the **current** + OctoPrint version and that it does not vanish when you start OctoPrint + in [safe mode](https://docs.octoprint.org/en/master/features/safemode.html) either. + + + Thank you for your collaboration! + - type: textarea + attributes: + label: The problem + description: >- + Describe the issue you are experiencing here. Tell us what you were trying to do + step by step, and what happened that you did not expect. + + Provide a clear and concise description of what the problem is and include as many + details as possible. + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: dropdown + attributes: + label: Did the issue persist even in safe mode? + description: >- + Testing in safe mode is required to make sure the issue you are reporting is not + caused by a third party plugin. Please see [here](https://docs.octoprint.org/en/master/features/safemode.html) + on how to run OctoPrint in safe mode. + options: + - Yes, it did persist + - No, it did not persist (it's an issue with one of your installed plugins, don't report here) + - I cannot test this issue in safe mode (state why below) + validations: + required: true + - type: input + attributes: + label: If you could not test in safe mode, please state why + description: >- + Issues caused by a third party plugin are a major cause of bugs reported here, so we really need to + rule out that a plugin is at fault here. [Safe mode](https://docs.octoprint.org/en/master/features/safemode.html) is an easy way to do that. Only skip safe mode + if your particular problem *absolutely requires* third party plugins to be enabled. "It would take + too long" is **not** a reason to skip testing in safe mode, neither is "I do not know how to + enable it" as you can find info on that [here](https://docs.octoprint.org/en/master/features/safemode.html). + + If you really *cannot* test in safe mode, leave a short explanation as to why. + - type: markdown + attributes: + value: | + ## Environment + - type: input + attributes: + label: Version of OctoPrint + description: Can be found in the lower left corner of the web interface. + validations: + required: true + - type: input + attributes: + label: Operating system running OctoPrint + description: >- + OctoPi, Linux, Windows, MacOS, something else? With version please? OctoPi's + version can be found in `/etc/octopi_version` or in the lower left corner of the + web interface. + validations: + required: true + - type: input + attributes: + label: Printer model & used firmware incl. version + description: If applicable, always include if unsure + - type: input + attributes: + label: Browser and version of browser, operating system running browser + description: If applicable, always include if unsure + - type: markdown + attributes: + value: | + ## Logs and other files needed for analysis + - type: markdown + attributes: + value: >- + Please also be sure to upload the following files below: + + * Systeminfo Bundle: See [here](https://community.octoprint.org/t/what-is-a-systeminfo-bundle-and-how-can-i-obtain-one/29887) if you don't know where to find that. Just attach down below as-is. Note that you'll need at least OctoPrint 1.6.0 for this to be available - we no longer accept bug reports created for older versions than this. + * If you are reporting an issue that involves communicating with you printer, **be sure to enable `serial.log` before reproducing and creating the Systeminfo Bundle**! + * Your browser's JavaScript console, if you are reporting a problem with the + user interface. See [here](https://webmasters.stackexchange.com/questions/8525/how-to-open-the-javascript-console-in-different-browsers) on where to find that. + * If possible, screenshots or videos showing the problem, especially if you + are reporting a problem with the user interface! + * GCODE files with which to reproduce, if you are reporting an issue with + GCODE file analysis or printing behaviour. + + Please be aware that unless at least Systeminfo Bundle is included, your bug report + will not be processed and closed after a while. + - type: checkboxes + attributes: + label: Checklist of files to include below + options: + - label: Systeminfo Bundle (always include!) + required: true + - label: Contents of the JavaScript browser console (always include in cases of issues with the user interface) + - label: Screenshots and/or videos showing the problem (always include in case of issues with the user interface) + - label: GCODE file with which to reproduce (always include in case of issues with GCODE analysis or printing behaviour) + - type: textarea + attributes: + label: Additional information & file uploads diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..ed748bff5b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +contact_links: + - name: 🤔 Have questions or need support? + url: https://community.octoprint.org + about: Please get in touch on the OctoPrint Community Forums! + - name: 💸 Want to donate? + url: https://support.octoprint.org + about: Please take a look at the various options to support OctoPrint's development financially! diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..aea43bdefb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,34 @@ +name: ✨ Request a feature +description: Request a new feature to implement in OctoPrint +title: "[Request]" +body: + - type: markdown + attributes: + value: > + **Thank you for wanting to request a feature in OctoPrint!** + + + Before you go ahead with your request, please first consider if it wouldn't be + better suited for a plugin. As a general rule of thumb, any feature that is only + of interest to a small sub group should be moved into a plugin. Only such features + that are of interest to a majority of OctoPrint's users will be implemented as + part of the core itself. + + - type: textarea + attributes: + label: Is your feature request related to a problem? Please describe. + description: A clear and concise description of what the problem is. Eg, "I'm always frustrated when [...]". + - type: textarea + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. Explain why it needs to be included in OctoPrint's core and can't become a plugin. + validations: + required: true + - type: textarea + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + - type: textarea + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4c18b123cf..9d7dce15ba 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,4 @@ + * [ ] Your changes are not possible to do through a plugin and relevant to a large audience (ideally all users of OctoPrint) @@ -14,14 +16,16 @@ checklist: made sure your changes don't interfere with current development by talking it through with the maintainers, e.g. through a Brainstorming ticket - * [ ] Your PR targets OctoPrint's devel branch, or maintenance if it's - a bug fix for an issue present in the current stable version (no PRs - against master or anything else) + * [ ] Your PR targets OctoPrint's devel branch if it's a completely + new feature, or maintenance if it's a bug fix or improvement of + existing functionality for the current stable version (no PRs + against master or anything else please) * [ ] Your PR was opened from a custom branch on your repository (no PRs from your version of master, maintenance or devel please), - e.g. dev/my_new_feature + e.g. dev/my_new_feature or fix/my_bugfix * [ ] Your PR only contains relevant changes: no unrelated files, - no dead code, ideally only one commit - rebase your PR if necessary! + no dead code, ideally only one commit - rebase and squash your PR + if necessary! * [ ] Your changes follow the existing coding style * [ ] If your changes include style sheets: You have modified the .less source files, not the .css files (those are generated with @@ -32,11 +36,10 @@ checklist: nothing broke * [ ] You have added yourself to the AUTHORS.md file :) -Feel free to delete all this help text, then describe -your PR further. You may use the template provided below to do that. -The more details the better! - ----- + #### What does this PR do and why is it necessary? diff --git a/.github/fixtures/with_acl/config.yaml b/.github/fixtures/with_acl/config.yaml new file mode 100644 index 0000000000..57fadccbf4 --- /dev/null +++ b/.github/fixtures/with_acl/config.yaml @@ -0,0 +1,18 @@ +accessControl: + salt: zXmvzI3uWuTLkSPOEfA2ZLwn3f3sGUNS +plugins: + virtual_printer: + enabled: true + tracking: + enabled: false +server: + firstRun: false + onlineCheck: + enabled: false + pluginBlacklist: + enabled: false + secretKey: rrdHjvmeGMPSdYWg2NGdiM7mpz9pjekk + seenWizards: + backup: null + corewizard: 3 + tracking: null diff --git a/.github/fixtures/with_acl/users.yaml b/.github/fixtures/with_acl/users.yaml new file mode 100644 index 0000000000..7ac3a086d9 --- /dev/null +++ b/.github/fixtures/with_acl/users.yaml @@ -0,0 +1,22 @@ +admin: + active: true + apikey: null + groups: + - admins + - users + password: 3033587f3b040680c77ce4fb7e8d61422878ef49a2a784227689f3b44ce66ada7f6161f8b2b037267f0d37b783a5600276eb88e1d224d24915f64e187a49a89f + permissions: [] + roles: + - user + - admin + settings: {} +user: + active: true + apikey: null + groups: + - users + password: 3033587f3b040680c77ce4fb7e8d61422878ef49a2a784227689f3b44ce66ada7f6161f8b2b037267f0d37b783a5600276eb88e1d224d24915f64e187a49a89f + permissions: [] + roles: + - user + settings: {} diff --git a/.github/issue-validation.yml b/.github/issue-validation.yml new file mode 100644 index 0000000000..763ca73a2c --- /dev/null +++ b/.github/issue-validation.yml @@ -0,0 +1,62 @@ +approve_label: approved +problem_label: incomplete +ignored_labels: + - approved + - bug + - request + - improvement + - brainstorming + - task + - rc feedback + - awaiting information + - not octoprint + - done +ignored_authors: + - "@OctoPrint" + - "sentry-io" + - "FormerLurker" + - "bzed" + +validation_comment: > + Hi @@@AUTHOR@@, + + + it looks like there is some **information missing** from your bug report that will + be needed in order to solve the problem. Read the [Contribution Guidelines](https://github.com/OctoPrint/OctoPrint/blob/master/CONTRIBUTING.md) + which will provide you with a template to *fully fill out* here so that your bug report + is ready to be investigated (I promise I'll go away then too!). + + + If you did not intend to report a bug but wanted to **request a feature or brain + storm** about some kind of development, please take special note of the title format + to use as described in the [Contribution Guidelines](https://github.com/OctoPrint/OctoPrint/blob/master/CONTRIBUTING.md). + + + **Please do not abuse the bug tracker as a support forum** - that can be found at + [community.octoprint.org](https://community.octoprint.org). Go there for any kind + of issues with network connectivity, webcam functionality, printer detection or + any other kind of such support requests or general questions. + + + Also **make sure you are at the right place** - this is the bug tracker of the official + version of OctoPrint, not the Raspberry Pi image OctoPi nor any unbundled third + party OctoPrint plugins or unofficial versions. Make sure too that you have **read + through the [Frequently Asked Questions](http://faq.octoprint.org)** and searched + the [**existing tickets**](https://github.com/OctoPrint/OctoPrint/issues) + for your problem - try multiple search terms please. + + + I'm marking this one now as needing some more information. Please understand that + if you do not provide that information within the next two weeks + I'll close this ticket so it doesn't clutter the bug tracker. This is nothing + personal, it's needed to keep this project manageable. Please just be considerate + and help the maintainers solve this problem + quickly by following the guidelines linked above. Remember, the less time the devs + have to spend running after information on tickets, the more time they have to actually + solve problems and add awesome new features. Thank you! + + + *I'm just a bot 🤖, not a human being, so don't expect any replies + from me :) Your ticket is read by humans too, I'm just not one of them.* + +keyphrase: I have read the FAQ diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000000..ed3f1c77a1 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,25 @@ +docs: + - "docs/*" + - "docs/**/*" + - "README.md" + +meta: + - "CODE_OF_CONDUCT.md" + - "CONTRIBUTING.md" + - "SECURITY.md" + - ".github/CODEOWNERS" + - ".github/FUNDING.yml" + - ".github/ISSUE_TEMPLATE/*" + - ".github/PULL_REQUEST_TEMPLATE.md" + +ci/cd: + - ".github/fixtures/**/*" + - ".github/workflows/*" + - ".github/labeler.yml" + +docker: + - ".github/workflows/trigger_docker.yml" + +tests: + - "tests/*" + - "tests/**/*" diff --git a/.github/pr-validation.yml b/.github/pr-validation.yml new file mode 100644 index 0000000000..6419bef1cb --- /dev/null +++ b/.github/pr-validation.yml @@ -0,0 +1,14 @@ +approve_label: "approved" +problem_label: "needs some work" +ignore_label: "skip validation" + +allowed_targets: ["maintenance", "devel", "staging/maintenance", "staging/devel"] +forbidden_sources: ["master", "maintenance", "devel", "staging/maintenance", "staging/devel"] + +labels: + docs: + additional_allowed_targets: ["master"] + meta: + additional_allowed_targets: ["master"] + ci/cd: + additional_allowed_targets: ["master"] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..2c72cb4361 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,302 @@ +name: Build +on: + push: + pull_request: + release: + types: [published] + workflow_dispatch: + +jobs: + build: + name: 🔨 Build distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: 🏗 Set up Python "3.10" + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: 🏗 Install build dependencies + run: | + python -m pip install wheel --user + - name: 🔨 Build a binary wheel and a source tarball + run: | + python setup.py sdist bdist_wheel + - name: ⬆ Upload build result + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist + + pre-commit: + name: 🧹 Pre-commit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: 🏗 Set up Python "3.10" + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: 🏗 Set up dev dependencies + run: | + pip install -e .[develop] + # TEMPORARY DISABLED to avoid log spam + #- name: 🚀 Run pre-commit + # run: | + # pre-commit run --all-files --show-diff-on-failure + + test-unit: + name: 🧪 Unit tests + strategy: + matrix: + python: ["3.10", "3.13"] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: 🏗 Set up Python ${{ matrix.python }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Fix Versioneer Compatibility + run: | + python -m pip install --upgrade pip + python -m pip install "setuptools<71.0.0" "packaging<22.0" + - name: 🏗 Set up test dependencies + run: | + pip install -e .[develop] + - name: 🚀 Run test suite + env: + PYTEST_DISABLE_PLUGIN_AUTOLOAD: "1" + PYTHONWARNINGS: default + run: | + pytest -p pytest_doctest_custom + + test-install: + name: 🧪 Installation tests + needs: build + strategy: + matrix: + python: ["3.10", "3.13"] + installable: ["wheel", "sdist"] + runs-on: ubuntu-latest + steps: + - name: ⬇ Download build result + uses: actions/download-artifact@v4 + with: + name: dist + path: dist + - name: 🏗 Set up Python ${{ matrix.python }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: 🚀 Install wheel + if: matrix.installable == 'wheel' + run: | + # Verify the file exists before attempting install + ls -R dist/ + pip install dist/octoprint-*.whl + - name: 🚀 Install source tarball + if: matrix.installable == 'sdist' + run: | + # Verify the file exists before attempting install + ls -R dist/ + pip install dist/octoprint-*.tar.gz + + test-e2e: + name: 🧪 E2E tests + needs: build + runs-on: ubuntu-latest + steps: + - name: ⬇ Checkout code + uses: actions/checkout@v4 + + - name: ⬇ Download build result + uses: actions/download-artifact@v4 + with: + name: dist + path: dist + - name: 🏗 Set up Python "3.10" + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: 🚀 Install wheel + run: | + ls -R dist/ + pip install --upgrade pip setuptools + # Force reinstall to ensure your NEW pins in setup.py are actually applied + pip install --force-reinstall dist/octoprint-*.whl + + - name: 🏗 Create base config for test server + run: | + mkdir e2econfig + cp -r .github/fixtures/with_acl/* e2econfig + + - name: 🚀 Run Cypress + uses: cypress-io/github-action@v6 + with: + working-directory: tests/cypress + browser: chrome + start: "octoprint -b ${{ github.workspace }}/e2econfig serve --host 127.0.0.1 --port 5000" + wait-on: "http://127.0.0.1:5000/online.txt" + + - name: ⬆ Upload screenshots + uses: actions/upload-artifact@v4 + if: failure() + with: + name: cypress-screenshots + path: tests/cypress/screenshots + - name: ⬆ Upload videos + uses: actions/upload-artifact@v4 + if: always() + with: + name: cypress-videos + path: tests/cypress/videos + + publish-on-testpypi: + name: 📦 Publish on TestPyPI + if: github.event_name == 'release' + needs: + - pre-commit + - test-unit + - test-install + - test-e2e + runs-on: ubuntu-latest + steps: + - name: ⬇ Download build result + uses: actions/download-artifact@v4 + with: + name: dist + path: dist + - name: 📦 Publish to index + uses: pypa/gh-action-pypi-publish@master + continue-on-error: true + with: + password: ${{ secrets.testpypi_password }} + repository_url: https://test.pypi.org/legacy/ + + publish-on-pypi: + name: 📦 Publish tagged releases to PyPI + if: github.event_name == 'release' + needs: publish-on-testpypi + runs-on: ubuntu-latest + steps: + - name: ⬇ Download build result + uses: actions/download-artifact@v4 + with: + name: dist + path: dist + - name: 📦 Publish to index + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.pypi_password }} + + notify-custopizer-build: + name: 📧 Notify OctoPi-UpToDate + if: github.event_name == 'release' + needs: publish-on-pypi + runs-on: ubuntu-latest + steps: + - name: 👀 Determine version + run: | + OCTOPRINT_VERSION=$(echo $GITHUB_REF | cut -d/ -f3) + echo "OCTOPRINT_VERSION=$OCTOPRINT_VERSION" >> $GITHUB_ENV + - name: 🚀 Repository Dispatch + uses: peter-evans/repository-dispatch@v1 + with: + token: ${{ secrets.REPODISPATCH_TOKEN }} + repository: OctoPrint/OctoPi-UpToDate + event-type: octoprint_release + client-payload: '{"version": "${{ env.OCTOPRINT_VERSION }}"}' + + + # debug-e2e-local: + # runs-on: ubuntu-22.04 + # steps: + # - uses: actions/checkout@v4 + # + # # COMMENT OUT OR REMOVE THIS BLOCK + # # - name: 🏗 Set up Python "3.10" + # # uses: actions/setup-python@v5 + # # with: + # # python-version: "3.10" + # + # - name: 🔧 Prepare System Dependencies (Python + Cypress) + # run: | + # apt-get update + # + # # 1. Python Build Dependencies + # apt-get install -y python3-pip python3-venv python3-dev build-essential + # + # # 2. Cypress & Browser Dependencies (Xvfb, GTK, etc.) + # apt-get install -y xvfb libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth + # + # # Alias python3 + # if ! command -v python &> /dev/null; then + # ln -s /usr/bin/python3 /usr/bin/python + # fi + # python --version + # + # # --- BUILD PHASE --- + # - name: 🔨 Build Wheel Locally + # run: | + # # Use user install to avoid permission issues in act, or root is fine + # pip install --upgrade pip build + # python -m build --wheel --outdir dist/ + # + # # --- INSTALL PHASE --- + # - name: 🚀 Install OctoPrint and Dependencies + # run: | + # pip install --upgrade pip + # pip install setuptools packaging + # # Force reinstall to overwrite any cached versions + # pip install --ignore-installed --force-reinstall dist/octoprint-*.whl + # + # # --- DEBUG PHASE --- + # - name: 🕵️ DEBUG - Verify Code Content + # run: | + # echo "🔎 Checking installed PluginAssetResolver..." + # # Get the correct site-packages path for the current python + # SITE_PACKAGES=$(python -c "import site; print(site.getsitepackages()[0])") + # FLASK_FILE="$SITE_PACKAGES/octoprint/server/util/flask.py" + # + # echo "Target File: $FLASK_FILE" + # + # # We look for the TAG loop logic we wrote + # if grep -q "octoprint_plugin_identifier" "$FLASK_FILE"; then + # echo "✅ PATCH FOUND: flask.py has the correct logic." + # # Optional: Print the context to be 100% sure + # grep -C 2 "octoprint_plugin_identifier" "$FLASK_FILE" + # else + # echo "❌ PATCH MISSING: flask.py is running old code!" + # echo "--- DUMPING FILE CONTENT ---" + # cat "$FLASK_FILE" + # exit 1 + # fi + # + # - name: ⚙️ Configure OctoPrint (Disable Wizard & ACL) + # run: | + # # 1. Ensure the config directory exists + # mkdir -p ${{ github.workspace }}/e2econfig + # + # # 2. Append the First Run Wizard disable flag to config.yaml + # # We use '>>' to append or create the file + # echo "server:" >> ${{ github.workspace }}/e2econfig/config.yaml + # echo " firstRunWizard: false" >> ${{ github.workspace }}/e2econfig/config.yaml + # echo " onlineCheck: false" >> ${{ github.workspace }}/e2econfig/config.yaml + # + # # 3. Verify content + # cat ${{ github.workspace }}/e2econfig/config.yaml + # + # # --- TEST PHASE --- + # - name: 🧪 Run Cypress + # uses: cypress-io/github-action@v6 + # with: + # working-directory: tests/cypress + # # Start server with the config path derived from github.workspace + # start: octoprint -b ${{ github.workspace }}/e2econfig serve --iknowwhatimdoing --host 127.0.0.1 --port 5000 + # wait-on: 'http://127.0.0.1:5000/online.txt' + # wait-on-timeout: 60 diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml new file mode 100644 index 0000000000..80ff4f9577 --- /dev/null +++ b/.github/workflows/cleanup.yml @@ -0,0 +1,37 @@ +name: "Cleanup issues & PRs" + +on: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + lock: + name: 🔒 Lock issues & PRs closed over a year + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@v2 + with: + github-token: ${{ github.token }} + issue-lock-comment: "" + pr-lock-comment: "" + + cleanup: + name: 🧹 Close issues marked as incomplete & older than 14 days + runs-on: ubuntu-latest + steps: + - id: date + run: | + echo "::set-output name=CUTOFF::`date --date='14 days ago' +'%Y-%m-%d'`" + - uses: OctoPrint/actions/close-by-query@main + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + query: "is:issue is:open label:incomplete created:<${{ steps.date.outputs.CUTOFF }}" + comment: > + Since apparently some of the required information is still missing, this will be + closed now, sorry. Feel free to request a reopen of this or create a new issue + once you can provide **all** + [required information](https://github.com/OctoPrint/OctoPrint/blob/master/CONTRIBUTING.md#how-to-file-a-bug-report). + + + This is nothing personal. Thank you for your collaboration. diff --git a/.github/workflows/issue_automation.yml b/.github/workflows/issue_automation.yml new file mode 100644 index 0000000000..3e5b9e0162 --- /dev/null +++ b/.github/workflows/issue_automation.yml @@ -0,0 +1,161 @@ +name: "Issue Automation" +on: + issues: + types: [opened, edited, closed, reopened, labeled, unlabeled] + +jobs: + issue-automation: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v5 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + let labels = context.payload.issue.labels.map(label => label.name); + let setLabels = false; + + switch (context.payload.action) { + + case 'opened': + case 'edited': { + if (context.payload.issue.title.match(/\[request\]|feature request/i)) { + labels.push('request'); + labels = labels.filter(label => label !== 'triage'); + setLabels = true; + } + if (context.payload.issue.title.match(/\[rc feedback\]/i)) { + labels.push('rc feedback'); + labels = labels.filter(label => label !== 'triage'); + setLabels = true; + } + + if (labels.length === 0) { + labels.push('triage'); + setLabels = true; + } + break; + } + + case 'closed': { + if (labels.includes('bug') || labels.includes('request') || labels.includes('improvement')) { + labels.push('done'); + setLabels = true; + } + break; + } + + case 'reopened': { + if (labels.includes('done')) { + labels = labels.filter(label => label !== 'done'); + setLabels = true; + } + break; + } + } + + if (setLabels) { + github.rest.issues.setLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: labels + }) + } + + #- uses: OctoPrint/actions/issue-validation@main + # if: github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'edited') + # with: + # repo-token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/github-script@v5 + env: + REMINDER: > + Hi @${{ github.event.issue.user.login }}! + + + It looks like you didn't upload a [system info bundle](https://community.octoprint.org/t/29887) as requested by the template. + A bundle is required to further process your issue. It contains important logs and + system information to be able to put your issue into context and give pointers as to + what has happened. + + + Please **edit your original post above** and upload **a bundle zip file**. Actually upload the file please and + do not paste some link to a cloud provider, we want to have everything in one place here. Also do + not unpack, repack or otherwise modify the bundle or its name, share it **exactly** like you get it from OctoPrint. + + + Without the availability of a bundle, your issue will have to be closed. + + + Thank you for your collaboration. + THANKYOU: > + Thank you @${{ github.event.issue.user.login }} for adding a bundle! Now this can actually get looked at. + with: + script: | + const { REMINDER, THANKYOU } = process.env; + const bundleRegex = /\[(octoprint-systeminfo-\d{14}\.zip)\]\(([^)]+)\)/g; + const marker = ""; + + const issueLabels = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + let labels = issueLabels.data.map(label => label.name); + + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }) + const comment = comments.data.find(c => c.user.login === "github-actions[bot]" && c.body.includes(marker)); + if (comment) { + console.log("Found comment, id=" + comment.id); + } else { + console.log("No comment found"); + } + + if (!labels.includes("triage") || labels.includes("approved")) { + console.log("Deleting comment if it exists..."); + if (comment) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }) + } + return; + } + + const found = !!context.payload.issue.body.match(bundleRegex); + + if (!found) { + console.log("No bundle found, posting/updating reminder"); + const text = REMINDER + "\n" + marker; + if (!comment) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: text + }); + } else if (comment.body !== text) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + body: text + }); + } + } else if (found && comment) { + console.log("Bundle found, saying thanks"); + const text = REMINDER.split("\n\n").map(line => `~~${line.trim()}~~`).join("\n\n") + "\n\n" + THANKYOU + "\n" + marker; + if (comment.body !== text) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + body: text + }); + } + } diff --git a/.github/workflows/linkify_bundles.yml b/.github/workflows/linkify_bundles.yml new file mode 100644 index 0000000000..9e05538915 --- /dev/null +++ b/.github/workflows/linkify_bundles.yml @@ -0,0 +1,54 @@ +name: "Linkify systeminfo bundles" +on: + issue_comment: + types: [created, edited] + +jobs: + linkifyBundles: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + let comment = context.payload.comment.body; + + const botMarker = ""; + const botWasHere = comment.indexOf(botMarker); + if (botWasHere !== -1) { + comment = comment.substring(0, botWasHere); + }; + + const bundleRegex = /\[(octoprint-systeminfo-\d{14}\.zip)\]\(([^)]+)\)/g; + + const bundles = []; + const seen = []; + const matches = comment.matchAll(bundleRegex); + for (const match of matches) { + if (!seen.includes(match[1])) { + bundles.push({filename: match[1], link: match[2], match: match[0]}); + seen.push(match[1]); + } + }; + + if (bundles.length > 0) { + let text = comment + botMarker + "\n\n---\n\n\nBundles:\n\n"; + bundles.forEach((bundle) => { + text += " * [" + bundle.filename + "](https://bundleviewer.octoprint.org/?url=" + encodeURIComponent(bundle.link) + ")\n" + }); + text += "\n*edited by @github-actions to add bundle viewer links*\n"; + + github.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + body: text + }); + } else if (botWasHere !== -1) { + github.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + body: comment + }); + } diff --git a/.github/workflows/nightly_merge.yml b/.github/workflows/nightly_merge.yml new file mode 100644 index 0000000000..9b4761fbdc --- /dev/null +++ b/.github/workflows/nightly_merge.yml @@ -0,0 +1,33 @@ +name: "Nightly merge" + +on: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + nightly-merge: + runs-on: ubuntu-latest + steps: + - name: ⬇ Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 🔀 Merge master into maintenance + uses: robotology/gh-action-nightly-merge@v1.3.2 + with: + stable_branch: "master" + development_branch: "maintenance" + allow_ff: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + #- name: 🔀 Merge maintenance into devel + # uses: robotology/gh-action-nightly-merge@v1.3.2 + # with: + # stable_branch: "maintenance" + # development_branch: "devel" + # allow_ff: false + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr_automation.yml b/.github/workflows/pr_automation.yml new file mode 100644 index 0000000000..cc411d1210 --- /dev/null +++ b/.github/workflows/pr_automation.yml @@ -0,0 +1,45 @@ +name: "Pull Request Automation" +on: + pull_request_target: + #types: ["opened", "synchronize", "reopened", "edited", "labeled", "unlabeled"] + types: ["opened"] + +jobs: + pr-automation: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v3 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + + - uses: actions/github-script@v5 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + // fetch a fresh set of labels, they might have changed earlier + const issueLabels = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + let labels = issueLabels.data.map(label => label.name); + let setLabels = false; + + let target = context.payload.pull_request.base.ref; + if (["maintenance", "devel", "staging/maintenance", "staging/devel", "master"].includes(target)) { + labels.push(`targets ${target}`); + setLabels = true; + } + + if (setLabels) { + github.rest.issues.setLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: labels + }) + } + + - uses: OctoPrint/actions/pr-validation@main + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test_install.yml b/.github/workflows/test_install.yml new file mode 100644 index 0000000000..88d46b86b2 --- /dev/null +++ b/.github/workflows/test_install.yml @@ -0,0 +1,77 @@ +name: Test install of branches and latest release twice per day + +on: + push: + branches: + - master + workflow_dispatch: + +jobs: + install-branch: + name: Install branch + strategy: + matrix: + os: ["ubuntu-latest"] + python: ["3.10", "3.13"] + branch: ["master"] + runs-on: ${{ matrix.os }} + steps: + - name: 🐍 Set up Python ${{ matrix.python }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: ⬇ Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ matrix.branch }} + fetch-depth: 0 + - name: 👷 Build and install checkout + run: | + pip install . + - name: 📧 Discord success notification + env: + DISCORD_WEBHOOK: ${{ secrets.discord_webhook }} + uses: Ilshidur/action-discord@master + with: + args: "☑️ Test install on Python ${{ matrix.python }} under `${{ matrix.os }}` for `OctoPrint/OctoPrint:${{ matrix.branch }}` was successful" + - name: 📧 Discord failure notification + if: failure() + env: + DISCORD_WEBHOOK: ${{ secrets.discord_webhook }} + uses: Ilshidur/action-discord@master + with: + args: "🚫 Test install on Python ${{ matrix.python }} under `${{ matrix.os }}` for `OctoPrint/OctoPrint:${{ matrix.branch }}` failed" + + install-latest: + name: Install latest release + strategy: + matrix: + os: ["ubuntu-latest"] + python: ["3.10", "3.13"] + runs-on: ${{ matrix.os }} + steps: + - name: 👀 Determine latest release + id: latest + uses: foosel/github-action-get-latest-release@master + with: + repository: "OctoPrint/OctoPrint" + - name: 🐍 Set up Python ${{ matrix.python }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: 👷 Build and install latest release + run: | + pip install https://github.com/OctoPrint/OctoPrint/archive/${{ steps.latest.outputs.release }}.zip + - name: 📧 Discord success notification + env: + DISCORD_WEBHOOK: ${{ secrets.discord_webhook }} + uses: Ilshidur/action-discord@master + with: + args: "☑️ Test install on Python ${{ matrix.python }} under `${{ matrix.os }}` for `OctoPrint/OctoPrint:${{ steps.latest.outputs.release }}` was successful" + - name: 📧 Discord failure notification + if: failure() + env: + DISCORD_WEBHOOK: ${{ secrets.discord_webhook }} + uses: Ilshidur/action-discord@master + with: + args: "🚫 Test install on Python ${{ matrix.python }} under `${{ matrix.os }}` for `OctoPrint/OctoPrint:${{ steps.latest.outputs.release }}` failed" diff --git a/.github/workflows/trigger_docker.yml b/.github/workflows/trigger_docker.yml new file mode 100644 index 0000000000..1a86bb0c68 --- /dev/null +++ b/.github/workflows/trigger_docker.yml @@ -0,0 +1,47 @@ +name: Trigger docker build +on: + release: + types: + - released + - prereleased + push: + branches: + - maintenance + - devel + +jobs: + dispatch_releases: + runs-on: ubuntu-latest + if: github.repository == 'OctoPrint/OctoPrint' && github.event_name != 'push' + steps: + - name: 🚀 Repository Dispatch + uses: peter-evans/repository-dispatch@v1 + with: + token: ${{ secrets.DOCKER_ACCESS_TOKEN }} + repository: ${{ github.repository_owner }}/octoprint-docker + event-type: ${{ github.event.action }} + client-payload: '{"tag_name": "${{ github.event.release.tag_name }}"}' + + dispatch_canary: + runs-on: ubuntu-latest + if: github.repository == 'OctoPrint/OctoPrint' && github.event_name == 'push' && github.ref == 'refs/heads/maintenance' + steps: + - name: 🚀 Repository Dispatch + uses: peter-evans/repository-dispatch@v1 + with: + token: ${{ secrets.DOCKER_ACCESS_TOKEN }} + repository: ${{ github.repository_owner }}/octoprint-docker + event-type: "canary" + client-payload: '{"tag_name": "maintenance"}' + + dispatch_bleeding: + runs-on: ubuntu-latest + if: github.repository == 'OctoPrint/OctoPrint' && github.event_name == 'push' && github.ref == 'refs/heads/devel' + steps: + - name: 🚀 Repository Dispatch + uses: peter-evans/repository-dispatch@v1 + with: + token: ${{ secrets.DOCKER_ACCESS_TOKEN }} + repository: ${{ github.repository_owner }}/octoprint-docker + event-type: "bleeding" + client-payload: '{"tag_name": "devel"}' diff --git a/.github/workflows/trigger_docs.yml b/.github/workflows/trigger_docs.yml new file mode 100644 index 0000000000..cb20852fbf --- /dev/null +++ b/.github/workflows/trigger_docs.yml @@ -0,0 +1,20 @@ +name: Trigger doc build +on: + push: + release: + types: + - released + - prereleased + +jobs: + trigger: + runs-on: ubuntu-latest + if: github.repository == 'OctoPrint/OctoPrint' + steps: + - name: 🚀 Repository Dispatch + uses: peter-evans/repository-dispatch@v1 + with: + token: ${{ secrets.REPODISPATCH_TOKEN }} + repository: OctoPrint/docs.octoprint.org + event-type: docs + client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}"}' diff --git a/.github/workflows/update-beamos-tags.yml b/.github/workflows/update-beamos-tags.yml new file mode 100644 index 0000000000..1f9f66155c --- /dev/null +++ b/.github/workflows/update-beamos-tags.yml @@ -0,0 +1,10 @@ +name: Update tags on mrbeam/mrb3-beamOS + +on: + release: + types: [published] + +jobs: + update-tags: + uses: mrbeam/mrb-workflows/.github/workflows/update-beamos-tags.yml@main + secrets: inherit diff --git a/.gitignore b/.gitignore index eeaf038e80..f330dc36c0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ src/octoprint/templates/_data/AUTHORS.md src/octoprint/templates/_data/CHANGELOG.md src/octoprint/templates/_data/SUPPORTERS.md src/octoprint/templates/_data/THIRDPARTYLICENSES.md +src/octoprint/plugins/pi_support/.local_debug +docs/_build devtools @@ -19,21 +21,27 @@ linux-Cura-* Printrun ======= .idea +.vscode .DS_Store Cura/current_profile.ini Cura/preferences.ini build dist pypy -OctoPrint.egg-info -*.orig +*.orig *.codekit -venv -venv26 -venv27 -venv33 -venv34 -venv35 -venv36 +*.egg-info +.eggs +.mypy_cache +.pytest_cache +.python-version +.tox + +venv* +testconf* + +node_modules +tests/cypress/screenshots +tests/cypress/videos diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..7445a45288 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,70 @@ +exclude: ^(src/octoprint/vendor/|src/octoprint/static/js/lib|src/octoprint/static/vendor|tests/static/js/lib|tests/util/_files|docs/|scripts/|translations/|.*\.css|.*\.svg) +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-case-conflict + - id: check-json + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: fix-encoding-pragma + args: ["--remove"] + exclude: "setup.py|versioneer.py|src/octoprint_setuptools/__init__.py" + - repo: https://github.com/asottile/pyupgrade + rev: v2.10.0 + hooks: + - id: pyupgrade + exclude: "setup.py|versioneer.py|src/octoprint_setuptools/__init__.py" + - repo: https://github.com/OctoPrint/codemods + rev: "0.6.2" + hooks: + - id: codemod_batch + args: + [ + "--check", + "not_in", + "--check", + "detect_past_builtins_imports", + "--ignore", + "src/octoprint/vendor", + "--ignore", + "setup.py", + "--ignore", + "versioneer.py", + "--ignore", + "src/octoprint_setuptools/__init__.py", + ] + - repo: https://github.com/pre-commit/mirrors-isort + rev: v5.5.4 + hooks: + - id: isort + - repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black + args: ["--config", "black.toml"] + additional_dependencies: + - click==8.0.4 + - repo: https://github.com/pycqa/flake8 + rev: 3.8.1 + hooks: + - id: flake8 + additional_dependencies: + - flake8-bugbear + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.1.2 + hooks: + - id: prettier + - repo: https://github.com/pre-commit/mirrors-eslint + rev: v7.19.0 + hooks: + - id: eslint + additional_dependencies: + - eslint@7.19.0 + - eslint-plugin-es5@v1.3.0 + - eslint-plugin-cypress@2.11.2 + files: \.js$ + exclude: ^(src/octoprint/vendor/|tests/static/js/lib|tests/util/_files|docs/|scripts/|translations/) diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..5c83e9d95b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,15 @@ +src/octoprint/static/js/lib +src/octoprint/static/vendor +src/octoprint/static/less/bootstrap +src/octoprint/static/less/font-awesome.less +*.md +*.css +**/package.json +**/package-lock.json +**/*.min.js +docs +build +dist +venv +venv2 +venv3 diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000000..a6abcbc164 --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,11 @@ +printWidth: 120 +tabWidth: 4 +semi: true +trailingComma: "none" +singleQuote: false +quoteProps: consistent +bracketSpacing: false +overrides: + - files: ["*.yaml", "*.yml", "*.json", "*.less"] + options: + tabWidth: 2 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index dd3b081eb4..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ ---- - -language: python -python: -- "2.7" -install: -- pip install -e .[develop] -script: -- nosetests --with-doctest -sudo: false -git: - depth: 250 diff --git a/.tx/config b/.tx/config index 33bd3437b8..402bc41dd9 100644 --- a/.tx/config +++ b/.tx/config @@ -5,4 +5,3 @@ host = https://www.transifex.com file_filter = translations//LC_MESSAGES/messages.po source_file = translations/messages.pot source_lang = en - diff --git a/.versioneer-lookup b/.versioneer-lookup index 06d670f005..10031a419b 100644 --- a/.versioneer-lookup +++ b/.versioneer-lookup @@ -7,27 +7,35 @@ # The file is processed from top to bottom, the first matching line wins. If or are left out, # the lookup table does not apply to the matched branches -# master and rc shall not use the lookup table, only tags -mrbeam2-dev -mrbeam2-stable +# master, meta, rc and prerelease shall not use the lookup table, only tags master +meta/.* rc/.* +hotfix/.* prerelease # neither should disconnected checkouts, e.g. 'git checkout ' HEAD \(detached.* -# maintenance is currently the branch for preparation of maintenance release 1.3.6 +# maintenance is currently the branch for preparation of maintenance release 1.7.0 # so are any fix/... and improve/... branches -maintenance 1.3.6 1a6dbb3f4a5bef857cdeb13c031b9deca2cf30a2 pep440-dev -fix/.* 1.3.6 1a6dbb3f4a5bef857cdeb13c031b9deca2cf30a2 pep440-dev -improve/.* 1.3.6 1a6dbb3f4a5bef857cdeb13c031b9deca2cf30a2 pep440-dev +maintenance 1.7.0 07c4630d126617c2ae98a9c23ad49facf7bf0bbd pep440-dev +fix/.* 1.7.0 07c4630d126617c2ae98a9c23ad49facf7bf0bbd pep440-dev +improve/.* 1.7.0 07c4630d126617c2ae98a9c23ad49facf7bf0bbd pep440-dev -# staging/maintenance is currently the branch for preparation of 1.3.6rc4 +# staging/bugfix is the branch for preparation of the 1.7.3 bugfix release +# so are any bug/... branches +staging/bugfix 1.7.3 d60c5a631aedf2328e4492125d9b86f595e43d00 pep440-dev +bug/.* 1.7.3 d60c5a631aedf2328e4492125d9b86f595e43d00 pep440-dev + +# staging/maintenance is currently the branch for preparation of 1.7.0rc4 # so is regressionfix/... -staging/maintenance 1.3.6rc4 a8747f7e36a03ff2449b62cdf68b8a26a6fa61b3 pep440-dev -regressionfix/.* 1.3.6rc4 a8747f7e36a03ff2449b62cdf68b8a26a6fa61b3 pep440-dev +staging/maintenance 1.7.0rc4 f31194f75365787f3eb5da61a24f2f8620111f4f pep440-dev +regressionfix/.* 1.7.0rc4 f31194f75365787f3eb5da61a24f2f8620111f4f pep440-dev + +# staging/devel is currently inactive (but has the 1.4.1rc4 namespace) +staging/devel 1.4.1rc4 650d54d1885409fa1d411eb54b9e8c7ff428910f pep440-dev -# every other branch is a development branch and thus gets resolved to 1.4.0-dev for now -.* 1.4.0 7f5d03d0549bcbd26f40e7e4a3297ea5204fb1cc pep440-dev +# every other branch is a development branch and thus gets resolved to 2.0.0.dev for now +.* 2.0.0 2da7aa358d950b4567aaab8f18d6b5779193e077 pep440-dev diff --git a/AUTHORS.md b/AUTHORS.md index a086022968..5cf6f796ca 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -69,12 +69,13 @@ date of first contribution): * [Philipp Baum](https://github.com/philphilphil) * [Kyle Evans](https://github.com/kevans91) * [Javier Martínez Arrieta](https://github.com/Javierma) - * ["MirceaDan"](https://github.com/ByReaL) + * [Mircea Dan Gheorghe](https://github.com/ByReaL) * [Ovidiu Hossu](https://github.com/MoonshineSG) * [Eyck Jentzsch](https://github.com/eyck) * [Mathias Rangel Wulff](https://github.com/mathiasrw) * [Clemens Niemeyer](https://github.com/clemniem) * ["I-am-me"](https://github.com/I-am-me) + * [J-J Heinonen](https://github.com/jammi) * [Noah Martin](https://github.com/noahsmartin) * [Eyal Soha](https://github.com/eyal0) * [Greg Hulands](https://github.com/ghulands) @@ -86,6 +87,69 @@ date of first contribution): * [Peter Backx](https://github.com/pbackx) * [Josh Major](https://github.com/astateofblank) * ["alex-gh"](https://github.com/alex-gh) + * [Bernd Zeimetz](https://github.com/bzed) + * [Daniele Forsi](https://github.com/dforsi) + * [Ganey](https://github.com/ganey) + * [Sven Lohrmann](https://github.com/malnvenshorn) + * [Andy Castille](https://github.com/klikini) + * [Zachary Nofzinger](https://github.com/ZachNo) + * [Gaston Dombiak](https://github.com/gdombiak) + * [Brad Fisher](https://github.com/bradcfisher) + * [Aldo Hoeben](https://github.com/fieldofview) + * [Henning Groß](https://github.com/hgross) + * [Jubaleth](https://github.com/jubaleth) + * [Matthias Urlichs](https://github.com/smurfix) + * [Daniel Joyce](https://github.com/DanielJoyce) + * [Andy Qua](https://github.com/AndyQ) + * [Fabio Santos](https://github.com/Fabi0San) + * [Jack Wilsdon](https://github.com/jackwilsdon) + * [Ryan Finnie](https://github.com/rfinnie) + * [Timur Duehr](https://github.com/tduehr) + * [Nicolai Nielsen](https://github.com/dovecode) + * [Janne Mäntyharju](https://github.com/JanneMantyharju) + * [Steven Spungin](https://github.com/flamenco) + * [Piotr Usewicz](https://github.com/pusewicz) + * [Aliaksei Pilko](https://github.com/aliaksei135) + * [Ben Yarmis](https://github.com/byarmis) + * [Florian Heilmann](https://github.com/FHeilmann) + * [Ludovico de Nittis](https://github.com/RyuzakiKK) + * [Dominik Paľo](https://github.com/DominikPalo) + * [Kelly Anderson](https://github.com/cbxbiker61) + * [Jim Neill](https://github.com/jneilliii) + * [Dustin Kerber](https://github.com/kerber) + * [Tobias Schürg](https://github.com/tobiasschuerg) + * [Josh Friend](https://github.com/joshfriend) + * [Lachlan Cresswell](https://github.com/lachyc) + * [Khoi Pham](https://github.com/osubuu) + * [Timofei Korostelev](https://github.com/chudsaviet) + * [Federico Nembrini](https://github.com/FedericoNembrini) + * [Uri Shaked](https://github.com/urish) + * [Brian Vanderbusch](https://github.com/LongLiveCHIEF) + * [Charlie Powell](https://github.com/cp2004) + * [Ollis Git](https://github.com/OllisGit) + * [Sophist](https://github.com/Sophist-UK) + * [Manuel McLure](https://github.com/ManuelMcLure) + * ["j7126"](https://github.com/j7126) + * ["coliss86"](https://github.com/coliss86) + * ["MichaIng"](https://github.com/MichaIng) + * ["jasonbcox"](https://github.com/jasonbcox) + * ["shadycuz"](https://github.com/shadycuz) + * [David Rifkind](https://github.com/drifkind) + * [Nils Hofmann](https://github.com/Master92) + * [Miroslav Šedivý](https://github.com/eumiro) + * [Costas Basdekis](https://github.com/costas-basdekis) + * [Joe Martella](https://github.com/martellaj) + * [Scott Lahteine](https://github.com/thinkyhead) + * [Karthikeyan Singaravelan](https://github.com/tirkarthi) + * [Yvan Rodrigues](https://github.com/TwoRedCells) + * [Elton Law](https://github.com/eltonlaw) + * ["sparxooo"](https://github.com/sparxooo) + * ["Stevil Knevil"](https://github.com/StevilKnevil) + * [ldursw](https://github.com/ldursw) + * [Ian Wilt](https://github.com/ianwiltdotcom) + * [Ben Sycha](https://github.com/Sycha) + * [Bryan Kenote](https://github.com/bryankenote) + * [Quinn Damerell](https://github.com/QuinnDamerell) OctoPrint started off as a fork of [Cura](https://github.com/daid/Cura) by [Daid Braam](https://github.com/daid). Parts of its communication layer and diff --git a/CHANGELOG.md b/CHANGELOG.md index 951e08c67e..ece6d517e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,1635 +1,4 @@ -# OctoPrint Changelog +# Changelog -## 1.3.6 (2017-12-12) - -### Note for upgraders and plugin authors: Change in the bundling of JS assets can lead to issues in plugins - -A change to solve issues with plugins bundling JS assets that cause interference with other plugins (e.g. through the declaration of `"use strict"`) and in general to add better isolation and error handling might cause errors for some plugins that go beyond your run-off-the-mill view model and also implicitly declare new globals. - -If you happen to run into any such issues, you can switch back to the old way of bundling JS assets via the newly introduced "Settings > Feature > Enable legacy plugin asset bundling" toggle (check it, save the settings, restart the server). This is provided to allow for a minimally invasive adjustment period until affected plugins have been updated. - -You can find out more about the change, how to know if a plugin is even affected and what do about it [on the OctoBlog](https://octoprint.org/blog/2017/12/01/heads-up-plugin-authors/). - -### Improvements - - * [#203](https://github.com/foosel/OctoPrint/issues/203) - Allow selecting the current tab via URL hashs. Also update URL hash when switching tabs, thus adding this to the browser history and allowing quicker back and forth navigation through the browser's back and forward buttons. - * [#1026](https://github.com/foosel/OctoPrint/issues/1026) - Automatically upper case parameters in GCODE commands sent from the Terminal tab. A black list is in place that prevent upper casing of parameters for GCODE commands where it doesn't make sense (default: `M117`). See also [#2177](https://github.com/foosel/OctoPrint/pull/2177). - * [#2050](https://github.com/foosel/OctoPrint/issues/2050) - New hook [`octoprint.comm.protocol.temperatures.received`](http://docs.octoprint.org/en/maintenance/plugins/hooks.html#octoprint-comm-protocol-temperatures-received) that allows plugins to further preprocess/sanitize temperature data received from the printer. - * [#2055](https://github.com/foosel/OctoPrint/issues/2055) - Increased the size of the API key field in the settings. - * [#2056](https://github.com/foosel/OctoPrint/issues/2056) - Added a Copy button to the API key field in the settings and user settings. - * [#2094](https://github.com/foosel/OctoPrint/issues/2094) - Allow UTF-8 display names for uploaded files. The files will still get an ASCII only name on disk, but the UTF-8 name used during upload will also be persisted and shown in the file list. This also allows using emojis in your file and folder names now. - * [#2104](https://github.com/foosel/OctoPrint/issues/2104) - Allow more URL schemes for installing plugins from. Supported schemes should now mirror what `pip` itself supports: `http`, `https`, `git`, `git+http`, `git+https`, `git+ssh`, `git+git`, `hg+http`, `hg+https`, `hg+static-http`, `hg+ssh`, `svn`, `svn+svn`, `svn+http`, `svn+https`, `svn+ssh`, `bzr+http`, `bzr+https`, `bzr+ssh`, `bzr+sftp`, `bzr+ftp`, `bzr+lp`. - * [#2109](https://github.com/foosel/OctoPrint/pull/2109) - New decorator `@firstrun_only_access` for API endpoints that should only be available before first setup has been completed. - * [#2111](https://github.com/foosel/OctoPrint/issues/2111) - Made the file list's scroll bar wider. - * [#2131](https://github.com/foosel/OctoPrint/issues/2131) - Added warning to restart, shutdown, reboot and update confirmations that that may disrupt ongoing prints, even those run from the printer's internal storage/SD. See also [#2146](https://github.com/foosel/OctoPrint/pull/2146) and [#2152](https://github.com/foosel/OctoPrint/pull/2152). - * [#2138](https://github.com/foosel/OctoPrint/issues/2138) - Slightly longer timeout when attempting to read from serial during auto detection via programming mode. Might help with detection of some slower printer controllers under certain circumstances. - * [#2200](https://github.com/foosel/OctoPrint/issues/2200) - Wrap all JS assets of plugins into one anonymous function per plugin. That way plugins using `"use strict";` won't cause hard to debug and weird issues with other plugins bundled after them. The down side is that plugins currently relying on implicit declaration of global helper functions or variables (`function convert(value) { ... }`) to be available outside of their own plugin's JS assets will now run into errors. To compensate for that while affected plugins are adjusted to declare globals explicitly (`window.convert = function(value) { ... }`), a temporary feature flag was added as "Settings > Features > Enable legacy plugin asset bundling" that switches back to the old form of bundling until plugins you rely on are updated. This flag will be removed again in a later version (currently planned for 1.3.8). See also the note above and [#2246](https://github.com/foosel/OctoPrint/issues/2246). - * [#2229](https://github.com/foosel/OctoPrint/issues/2229) - Added note to printer profile dialog that the nozzle offsets for multi extruder setups are only to be configured if they are not already set in the printer's firmware. - * [#2232](https://github.com/foosel/OctoPrint/issues/2232) - Disable movement distance buttons when not connected to the printer or when printing, since they don't have any use then. - * [#2239](https://github.com/foosel/OctoPrint/pull/2239) - Improved the check summing speed, thus improving the general achievable throughput on the comm layer. - * Allow cancelling of file transfers - * Made check of how old an unrendered timelapse is more lenient buy looking at both the creation and last modification date and using the younger one. - * Made notifications in general auto-close faster. - * Make the first profile saved for a slicer the default profile for that slicer. - * New command `server` for testing server connections on the [JS test API](http://docs.octoprint.org/en/maintenance/api/util.html#post--api-util-test). - * New hook [`octoprint.accesscontrol.keyvalidator`](http://docs.octoprint.org/en/maintenance/plugins/hooks.html#octoprint-accesscontrol-keyvalidator) that allows plugins to validate their own customized API keys to be used to access OctoPrint. - * Updated `cookiecutter`, `requests` and `psutil` dependencies. - * Added safety warning to first run wizard. - * More error resilience against broken view models. - * New sub command `octoprint safemode`. Will set the `server.startOnceInSafeMode` setting in the config so that the next (re)start of the server after issuing this command will happen in safe mode. - * New sub command `octoprint config effective`. Will report the effective config. - * New centralized plugin blacklist (opt-in). Allows to prevent plugins/certain versions of plugins known to cause crippling issues with the normal operation of OctoPrint to be disabled from loading, if the user has opted to do so in the settings/wizard. - * Log how to enable `serial.log` to `serial.log` if it's disabled. That will hopefully put at least a small dent in the amount of "It's empty!" responses in tickets ;) - * Force new Pypi index URL in `requirements.txt` as an additional work around against old tooling. - * Prefer plain `pip` over `git` for updating OctoPrint. - * Added environment detection and logging on startup. That should give us more information about the environment to produce a reported bug in. - * Added OctoPi support plugin that provides information about the detected OctoPi version. Will only load if OctoPi is detected. - * More dynamic plugin mixin detection. Now using a base class instead of having to list all types manually. Should greatly reduce overhead of adding new mixin types. - * Support leaf merging for file extension tree, allowing to add new file extensions to types registered by default. - * Allow non GCODE SD file transfers if registered as `machinecode` through e.g. a plugin's file extension hook. Caution: This doesn't make streaming arbitrary files to the printer via serial work magically. It merely allows that, it's up to the firmware to actually be able to handle that. Also, the regular GCODE streaming protocol is used, so if the streamed file contains control characters from that (e.g. `M29` to signal the end of the streaming process), stuff will break! - * Added a test button for the online connectivity check. - * Announcements plugin: Added UTM Tags. - * Cura plugin: Less `not configured yet` logging. - * GCODE viewer: Added advanced options that allow configuring display of bounding boxes, sorting by layers and hiding of empty layers. - * GCODE viewer: Persist all options to local storage so they will be automatically set again the next time the GCODE viewer is used in the same browser. - * Software update: Auto-hide "Everything is up-to-date" notification. - * Easier copying of terminal contents thanks to dedicated copy button. - * Timelapse: [#2067](https://github.com/foosel/OctoPrint/issues/2067) - Added rate limiting to z-based timelapse capturing to prevent issues when accidentally leaving this mode on with vase mode prints. - * Timelapse: Refactored configuration form & added reset button to switch back to currently active settings. - * Timelapse: Sort timelapses by modification instead of creation time (creation time can be newer if a backup restore was done). - * Virtual printer: Support configurable ambient temperature for testing. - * Virtual printer: Support configurable reset lines. - * Virtual printer: Added new debug trigger `trigger_missing_lineno`. - * Virtual printer: Allow empty/`None` prepared oks, allowing to simulate lost acknowledgements right on start. - * Docs: [#2142](https://github.com/foosel/OctoPrint/pull/2142) - Added documentation for the bundled virtual printer plugin. - * Docs: [#2234](https://github.com/foosel/OctoPrint/pull/2234) - Added info on how to install under Suse Linux. - * Docs: Added example PyCharm run configuration that includes automatic dependency updates on start. - * Docs: Added information on how to run the test suite. - * Various refactorings - * Various documentation updates - * Fetch plugin blacklist (and also announcements, plugin notices and plugin repository) via https instead of http. - -### Bug fixes - - * [#2044](https://github.com/foosel/OctoPrint/pull/2044) - Fix various typos in strings and comments - * [#2048](https://github.com/foosel/OctoPrint/pull/2048) & [#2176](https://github.com/foosel/OctoPrint/pull/2176) - Fixed various warnings during documentation generation. - * [#2077](https://github.com/foosel/OctoPrint/issues/2077) - Fix an issue with shared nozzles and the temperature graph, causing temperature to not be reported properly when another tool but the first one is selected. See also [#2077](https://github.com/foosel/OctoPrint/pull/2123) - * [#2108](https://github.com/foosel/OctoPrint/issues/2108) - Added no-op default action to login form so that username + password aren't sent as GET parameters if for some reason the user tries to log in before the view models are properly bound and thus the AJAX POST submission method is attached. - * [#2111](https://github.com/foosel/OctoPrint/issues/2111) - Prevent file list's scroll bar from fading out. - * [#2146](https://github.com/foosel/OctoPrint/issues/2147) - Fix initialization of temperature graph if it's not on the first tab due to tab reordering. - * [#2166](https://github.com/foosel/OctoPrint/issues/2166) - Workaround for a Firefox bug that causes the Drag-n-Drop overlay to never go away if the file is dragged outside of the browser window. - * [#2167](https://github.com/foosel/OctoPrint/issues/2167) - Fixed grammar of print time estimation tooltip - * [#2175](https://github.com/foosel/OctoPrint/issues/2175) - Cancel printing when an external reset of the printer is detected on the serial connection. - * [#2181](https://github.com/foosel/OctoPrint/issues/2181) - More resilience against non-standard `M115` responses. - * [#2182](https://github.com/foosel/OctoPrint/pull/2182) - Don't start tracking non existing or nonfunctional tools if encountering a temperature command referencing said tool. See also [kantlivelong/OctoPrint-PSUControl#68](https://github.com/kantlivelong/OctoPrint-PSUControl/issues/68). - * [#2196](https://github.com/foosel/OctoPrint/issues/2196) - Marked API key fields as `readonly` instead of `disabled` to allow their contents to be copied in Firefox (which wasn't possible before). - * [#2203](https://github.com/foosel/OctoPrint/issues/2203) - Reset temperature offsets to 0 when disconnected from the printer. - * [#2206](https://github.com/foosel/OctoPrint/issues/2206) - Disable pre-configured timelapse if snapshot URL of ffmpeg path are unset. - * [#2214](https://github.com/foosel/OctoPrint/issues/2214) - Fixed temperature fields not selecting in MS Edge on focus. - * [#2217](https://github.com/foosel/OctoPrint/pull/2217) - Fix an issue in `octoprint.util` causing a crash when running under PyPy instead of CPython. - * [#2226](https://github.com/foosel/OctoPrint/issues/2226) - Handle `No Line Number with checksum, Last Line: ...` errors from the firmware. - * [#2233](https://github.com/foosel/OctoPrint/pull/2233) - Respond with `411 Length Required` when content length is missing on file uploads. - * [#2242](https://github.com/foosel/OctoPrint/issues/2242) - Fixed an issue where print time left could show "1 days" instead of "1 day". - * [#2262](https://github.com/foosel/OctoPrint/issues/2262) (regression) - Fixed a bug causing `Error:checksum mismatch, Last Line: ...` errors from the firmware to be handled incorrectly. - * [#2267](https://github.com/foosel/OctoPrint/issues/2267) (regression) - Fixed a bug causing the GCODE viewer to not get properly initialized due to a JS error on load if "Also show next layer" was selected. - * [#2268](https://github.com/foosel/OctoPrint/issues/2268) (regression) - Fixed a bug causing a display error with the temperature offsets. If one offset was changed, all others seemed to revert back to 0. - * Fixed cleanup of unrendered timelapses with certain names. - * Fixed a caching issue with the file list API and the slicing API. - * Fixed initial sizing of the temperature graph. - * More resilience against corrupt `.metadata.yaml` files. - * More resilience against corrupt/invalid entries for system actions. - * More resilience against invalid JSON command requests. - * More resilience against broken packages in the python environment. - * Don't evaluate `onWebcamLoaded` more than once when switching to the webcam tab. - * Fixed `octoprint config` sub command. - * Fixed deactivated user accounts being able to login (albeit without a persistent session). Show fitting error instead. - * Fixed temperature auto report after an external reset. - * Don't log full request headers in Tornado on an error. - * Fix displayed notification message for synchronous system commands. Was accidentally swapped with the one for asynchronous system commands. - * GCODE viewer: Fix error on empty layers. - * Virtual printer: Fix resend simuation. - * Docs: Fixed CSS of line numbered listings. - * Docs: Updated mermaid to fix a deprecation warning. - * Fixed ordering of plugin assets, should be alphabetical based on the plugin identifier. (regression) - * Fixed an issue causing redundant software update configuration settings to be written to `config.yaml`, in turn causing issues when downgrading to <1.3.5. (regression) - * Fixed an issue detecting whether the installed version is a release version or a development version. (regression) - -### More Information - - * [Commits](https://github.com/foosel/OctoPrint/compare/1.3.5...1.3.6) - * Release Candidates: - * [1.3.6rc1](https://github.com/foosel/OctoPrint/releases/tag/1.3.6rc1) - * [1.3.6rc2](https://github.com/foosel/OctoPrint/releases/tag/1.3.6rc2) - * [1.3.6rc3](https://github.com/foosel/OctoPrint/releases/tag/1.3.6rc3) - * A special **Thank you!** to everyone who reported back on these release candidates this time: [andrivet](https://github.com/andrivet), [b-morgan](https://github.com/b-morgan), [bjarchi](https://github.com/bjarchi), [chippypilot](https://github.com/chippypilot), [ChrisHeerschap](https://github.com/ChrisHeerschap), [cosmith71](https://github.com/cosmith71), [Crowlord](https://github.com/Crowlord), [ctgreybeard](https://github.com/ctgreybeard), [fiveangle](https://github.com/fiveangle), [goeland86](https://github.com/goeland86), [jbjones27](https://github.com/jbjones27), [jneilliii](https://github.com/jneilliii), [JohnOCFII](https://github.com/JohnOCFII), [Kunsi](https://github.com/Kunsi), [Lordxv](https://github.com/Lordxv), [malnvenshorn](https://github.com/malnvenshorn), [mcp5500](https://github.com/mcp5500), [ntoff](https://github.com/ntoff), [ripp2003](https://github.com/ripp2003) and [schorsch3000](https://github.com/schorsch3000) - -## 1.3.5 (2017-10-16) - -### Improvements - - * [#1162](https://github.com/foosel/OctoPrint/pull/1162) - Allow `octoprint.comm.protocol.gcode.queuing` hook to return a list of commands. - * [#1572](https://github.com/foosel/OctoPrint/issues/1572) & [#1881](https://github.com/foosel/OctoPrint/issues/1881) - Refactored web interface startup process to minimise risk of race conditions and speed improvements. Also added sequence diagram to the documentation showing the new processing order. - * [#1640](https://github.com/foosel/OctoPrint/issues/1640) - Mouse over temperature graph to get exact data for that time. - * [#1679](https://github.com/foosel/OctoPrint/issues/1679) - Support temperature autoreporting by the firmware instead of polling if the firmware reports to support it. For this to work with Marlin 1.1.0 to 1.1.3 you'll need to explicitly enable `EXTENDED_CAPABILITIES_REPORT` *and* `AUTO_REPORT_TEMPERATURES` in your firmware configuration, otherwise your firmware won't report that it actually supports this feature. - * [#1737](https://github.com/foosel/OctoPrint/issues/1737) - Auto-detect Anet A8 firmware and treat as Repetier Firmware (which it actually appears to be, just renamed - thanks Anet for making this even harder). - * [#1842](https://github.com/foosel/OctoPrint/issues/1842) - Update bundled FontAwesome to 4.7 (see also [#1915](https://github.com/foosel/OctoPrint/pull/1915)). - * [#1910](https://github.com/foosel/OctoPrint/issues/1910) - Make last/pause/cancel temperature available for GCODE scripts. - * [#1925](https://github.com/foosel/OctoPrint/issues/1925) - Include configured webcam stream URL in "Webcam stream not loaded" message for logged in users/admins. Slightly different wording for guests vs users vs admins. - * [#1941](https://github.com/foosel/OctoPrint/issues/1941) - Enable "block while dwelling" flag when Malyan firmware is detected since that firmware seems to handle `G4` identically to Repetier Firmware instead of Marlin (like it claims to). See also [#1762](https://github.com/foosel/OctoPrint/issues/1762). - * [#1946](https://github.com/foosel/OctoPrint/issues/1946) - Add option to disable position logging on cancel/pause. See "Log position on cancel" and "Log position on pause" under Settings > Serial > Advanced options. - * [#1971](https://github.com/foosel/OctoPrint/issues/1971) - Refactored temperature inputs. They now sport some fancy +/- buttons to increment/decrement the current temperature (which also auto submit after a couple of seconds) and easier editing by keyboard. The temperature offset was also slightly redesigned to make room for that. - * [#1975](https://github.com/foosel/OctoPrint/issues/1975) - Better error reporting when deleting timelapses. - * [#2010](https://github.com/foosel/OctoPrint/pull/2010) - Slight refactoring in the terminal tab: Full width input field, auto focus of input field when just clicking on terminal output. - * [#2011](https://github.com/foosel/OctoPrint/issues/2011) - Centralized online connectivity check (with opt-in of course). None of the bundled plugins will attempt to fetch data from the net when the connectivity check indicates that would fail anyhow. This should improve server startup times and various requests when running isolated. - * [#2025](https://github.com/foosel/OctoPrint/issues/2025) - More verbose logging of asynchronous system commands (e.g. restart/shutdown). - * Allow timelapse configuration through UI even when not connected to the printer (suggested in [#1918](https://github.com/foosel/OctoPrint/issues/1918)) - * Disable "Upload to SD" UI elements while printing (suggested in [#1914](https://github.com/foosel/OctoPrint/issues/1914)) - * Update bundled SockJS to 1.1.2 incl. source maps - * Set `X-Robots` HTTP header and remove `Server` header from all responses, also set `robots` meta tag in page. - * Don't require to enter programmer mode for printer port autodetection when there's only one possible port candidate anyhow. - * More sensible sorting of baudrates (additionally configured, then 115200, then 250000, then everything else). - * Don't show "Unhandled communication error" on autodetection failure. - * Make timeout after which to unload the webcam stream after navigating away from it configurable (as suggested in [#1937](https://github.com/foosel/OctoPrint/issues/1937)) - * Add `ToolChange` event and tool change GCODE scripts - * Support parsing GCODE subcodes. - * Add `octoprint.users.factory` hook, allowing plugins to extend/swap out the user manager OctoPrint uses. - * Corewizard: Disable view model and client code if it's not actually required. - * Corewizard: Disable injection of JS files into UI when it's not actually required. - * GCODE analysis: Moved into its own subprocess. That should improve performance on multi core systems. - * GCODE viewer: Ignore coordinates outside bed when zooming/centering on model. Those usually are nozzle priming routines. - * JS Client Lib: Add centralized browser detection as `OctoPrint.coreui.browser`. Available properties: `chrome`, `firefox`, `safari`, `ie`, `edge`, `opera` as well as `mobile` and `desktop`, all of which are boolean values. - * Plugin Manager plugin: Detect if a plugin requires a reconnect to the printer to become fully active/inactive. - * Software Update plugin: Force exact version when updating OctoPrint and tracking releases. - * Software Update plugin: "Devel RCs" release channel now also tracks maintenance RCs. That way people don't have to toggle between the two any more to get *all* RCs. - * Software Update plugin: `bitbucket_commit` check type now supports API credentials (see also [#1993](https://github.com/foosel/OctoPrint/pull/1993)). - * Wizard: Allow suppressing the "the settings got updated" dialog through subwizards, in case they need to update settings asynchronously as part of their workflow. - * More resilience against expected folders being files. - * More resilience against a wrong user manager class being configured. - * Some code refactoring & cleanup. - * Some HTML & CSS improvements. - -### Bug fixes - - * [#1916](https://github.com/foosel/OctoPrint/issues/1916) - Fix webcam not loading if first/initial tab is "Control" - * [#1924](https://github.com/foosel/OctoPrint/issues/1924) - Filter out source map links from bundled JS webassets. - * [#1943](https://github.com/foosel/OctoPrint/issues/1943) - Fix issue causing unnecessary creation of default printer profile on startup - * [#1946](https://github.com/foosel/OctoPrint/issues/1946) - Decouple writing of print log from everything else. Fixes delay in cancel processing. - * [#1963](https://github.com/foosel/OctoPrint/issues/1963) & [#1974](https://github.com/foosel/OctoPrint/issues/1974) - Allow empty & custom size in print job events. Also fixes an issue with timelapses when printing from SD on printers that require the GPX plugin to work. - * [#1996](https://github.com/foosel/OctoPrint/issues/1996) - Support all line ending variantes in the GCODE viewer. Solves an issue with GCODE generated for Prusa's multi material extruder since that uses only `\r` for some reason. - * [#2007](https://github.com/foosel/OctoPrint/issues/2007) - Fix issue parsing temperature lines from the printer that contain negative values. - * [#2012](https://github.com/foosel/OctoPrint/issues/2012) - Fix command line interface of Software Update plugin. - * [#2017](https://github.com/foosel/OctoPrint/issues/2017) - Fix issue in GCODE viewer with files that contain a visit to the first layer twice (e.g. brim, then nose dive from higher z for actual model), causing all but the last layer segment to not be rendered. - * [#2033](https://github.com/foosel/OctoPrint/issues/2033) (regression) - Temperature tab: Fix for legend in graph not updating with current values on mouse over. - * [#2033](https://github.com/foosel/OctoPrint/issues/2033) (regression) - Temperature tab: Fix for new temperature inputs not fitting on one line in Firefox. - * [#2033](https://github.com/foosel/OctoPrint/issues/2033) (regression) - Temperature tab & GCODE viewer: Fix for available tools (and offsets) not properly updating on change of printer profile. - * [#2033](https://github.com/foosel/OctoPrint/issues/2033) (regression) - Wizard: Fix sorting of required wizards not properly handling non-ASCII unicode. - * [#2035](https://github.com/foosel/OctoPrint/issues/2035) (regression) - Fix an issue of the server not starting up if there's a file in the analysis backlog. The reason for this is that spawning a new process while the intermediary server is active causes the server port to be blocked (this is due to how subprocessing works by default), in turn leading to an error on startup since the port cannot be bound by the actual server. Since the GCODE analysis takes now place in its own subprocess and hence triggers this problem, it had to be moved until after the actual server has already started up to avoid this problem. - * [#2059](https://github.com/foosel/OctoPrint/issues/2059) (regression) - Fix an issue causing the new temperature controls to wrap on touch enabled devices when the temperature dropdown is opened. - * [#2090](https://github.com/foosel/OctoPrint/issues/2090) (regression) - Fix an issue causing an aborted server startup under Windows if the timing is just right. - * [#2135](https://github.com/foosel/OctoPrint/issues/2135) (regression) - Fix an issue causing import errors inside the GCODE analysis tool in certain environments due to `sys.path` entries causing relative imports. - * [#2136](https://github.com/foosel/OctoPrint/issues/2136) (regression) - Fix wrong minimum version for `sockjs-tornado` dependency. - * [#2137](https://github.com/foosel/OctoPrint/issues/2137) (regression) - Fix issue with session cookies getting lost when running an OctoPrint instance on a subpath of another (e.g. `octopi.local/` and `octopi.local/octoprint2`). - * [#2140](https://github.com/foosel/OctoPrint/issues/2140) (regression) - Fix issue with locale dependent sorting of sub wizards during first time setup causing issues leading to the wizard not being able to complete. - * Fix various popup buttons allowing multiple clicks (suggested in [#1914](https://github.com/foosel/OctoPrint/issues/1914)) - * Software Update Plugin: Perform server restart asynchronously. Should reduce restart times on updates significantly. - * Don't hex-escape `\t`, `\r` or `\n` in terminal output - * Use client side default printer profile if the default profile could not be found on the server - * Use both "to" and "from" coordinates of a given move for min/max model coordinate calculation in GCODE analysis. Otherwise wrong values could be calculated under certain circumstances. - * Fix potential race condition in resend request handling. - * Fix potential race condition in web socket handling. - * Fix handling of tool offsets in GCODE analysis - diverted too far from firmware implementations, causing wrong calculations. - * Fix `FileAdded`, `FileRemoved`, `FolderAdded`, `FolderRemoved` events not being fired with the correct event name. - * Fix potential division by zero in progress reporting if the timing was just right. - * Fix sorting order for multiple tools in the "State" panel. - * Fix file position vs. line ending handling in GCODE viewer. Could lead to slightly off file position calculation and hence possibly to the wrong move being plotted when synchronizing with print progress. - * Wizard: Fix `onWizardPreventSettingsRefreshDialog` callback invocation. (regression) - * Corewizard plugin: Fix `firstrunonly` wizards (e.g. for printer profile configuration) being displayed again if _any_ of the sub wizards (e.g. for the online check opt-in and configuration) is active. (regression) - * Fix an issue causing rollover of `serial.log` to fail under Windows. (regression) - -### More Information - - * [Commits](https://github.com/foosel/OctoPrint/compare/1.3.4...1.3.5) - * Release Candidates: - * [1.3.5rc1](https://github.com/foosel/OctoPrint/releases/tag/1.3.5rc1) - * [1.3.5rc2](https://github.com/foosel/OctoPrint/releases/tag/1.3.5rc2) - * [1.3.5rc3](https://github.com/foosel/OctoPrint/releases/tag/1.3.5rc3) - * [1.3.5rc4](https://github.com/foosel/OctoPrint/releases/tag/1.3.5rc4) - * A special **Thank you!** to everyone who reported back on these release candidates this time: [alexxy](https://github.com/alexxy), [andrivet](https://github.com/andrivet), [b-morgan](https://github.com/b-morgan), [BillyBlaze](https://github.com/BillyBlaze), [CapnBry](https://github.com/CapnBry), [chippypilot](https://github.com/chippypilot), [ctgreybeard](https://github.com/ctgreybeard), [cxt666](https://github.com/cxt666), [DaSTIG](https://github.com/DaSTIG), [fhbmax](https://github.com/fhbmax), [fiveangle](https://github.com/fiveangle), [goeland86](https://github.com/goeland86), [JohnOCFII](https://github.com/JohnOCFII), [Kunsi](https://github.com/Kunsi), [mgrl](https://github.com/mgrl), [MoonshineSG](https://github.com/MoonshineSG), [nate-ubiquisoft](https://github.com/nate-ubiquisoft), [Neoolog](https://github.com/Neoolog), [ntoff](https://github.com/ntoff), [oferfrid](https://github.com/oferfrid), [roygilby](https://github.com/roygilby), [SAinc](https://github.com/SAinc), [sbts](https://github.com/sbts), [thess](https://github.com/thess), [tkurbad](https://github.com/tkurbad), [tsillini](https://github.com/tsillini) and [TylonHH](https://github.com/TylonHH). - -## 1.3.4 (2017-06-01) - -### Note for owners of Malyan M200/Monoprice Select Mini - -OctoPrint's firmware autodetection is now able to detect this printer. Currently when this printer is detected, the following firmware specific features will be enabled automatically: - - * Always assume SD card is present (`feature.sdAlwaysAvailable`) - * Send a checksum with the command: Always (`feature.alwaysSendChecksum`) - -Since the firmware is a very special kind of beast and its sources are so far unavailable, only tests with a real printer will show if those are sufficient settings for communication with this printer to fully function correctly. Thus, if you run into any issues with enabled firmware autodetection on this printer model, please add a comment in [#1762](https://github.com/foosel/OctoPrint/issues/1762) and explain what kind of communication problem you are seeing. Also make sure to include a [`serial.log`](https://github.com/foosel/OctoPrint/blob/master/CONTRIBUTING.md#where-can-i-find-those-log-files-you-keep-talking-about)! - -### Bug fixes - - * [#1942](https://github.com/foosel/OctoPrint/issues/1942) - Fixed crash on startup in case of an invalid default printer profile combined with "auto-connect on startup" being selected and the printer available to connect to. - -### More information - - * [Commits](https://github.com/foosel/OctoPrint/compare/1.3.1...1.3.2) - * Release Candidates: - * None since this constitutes a hotfix release to fix an apparently very rare bug introduced with 1.3.3 that seems to be affecting a small number of users. - -## 1.3.3 (2017-05-31) - -### Note for owners of Malyan M200/Monoprice Select Mini - -OctoPrint's firmware autodetection is now able to detect this printer. Currently when this printer is detected, the following firmware specific features will be enabled automatically: - - * Always assume SD card is present (`feature.sdAlwaysAvailable`) - * Send a checksum with the command: Always (`feature.alwaysSendChecksum`) - -Since the firmware is a very special kind of beast and its sources are so far unavailable, only tests with a real printer will show if those are sufficient settings for communication with this printer to fully function correctly. Thus, if you run into any issues with enabled firmware autodetection on this printer model, please add a comment in [#1762](https://github.com/foosel/OctoPrint/issues/1762) and explain what kind of communication problem you are seeing. Also make sure to include a [`serial.log`](https://github.com/foosel/OctoPrint/blob/master/CONTRIBUTING.md#where-can-i-find-those-log-files-you-keep-talking-about)! - -### Improvements - - * [#478](https://github.com/foosel/OctoPrint/issues/478) - Made webcam stream container fixed height (with selectable aspect ratio) to prevent jumps of the controls beneath it on load. - * [#748](https://github.com/foosel/OctoPrint/issues/748) - Added delete confirmation and bulk delete for timelapses. See also the discussion in brainstorming ticket [#1807](https://github.com/foosel/OctoPrint/issues/1807). - * [#1092](https://github.com/foosel/OctoPrint/issues/1092) - Added new events to the file manager: `FileAdded`, `FileRemoved`, `FolderAdded`, `FolderRemoved`. Contrary to the `Upload` event, `FileAdded` will always fire when a file was added to storage through the file manager, not only when added through the web interface. Extended documentation accordingly. - * [#1521](https://github.com/foosel/OctoPrint/issues/1521) - Software update plugin: Display timestamp of last version cache refresh in "Advanced options" area. - * [#1734](https://github.com/foosel/OctoPrint/issues/1734) - Treat default/initial printer profile like all other printer profiles, persisting it to disk instead of `config.yaml` and allowing deletion. OctoPrint will migrate the existing default profile to the new location on first start. - * [#1734](https://github.com/foosel/OctoPrint/issues/1734) - Better communication of what actions are available for printer profiles. - * [#1739](https://github.com/foosel/OctoPrint/issues/1739) - Software update plugin: Added option to hide update notification from users without admin rights, added "ignore" button and note to get in touch with an admit to update notifications for non admin users. - * [#1762](https://github.com/foosel/OctoPrint/issues/1762) - Added Malyan M200/Monoprice Select Mini to firmware autodetection. - * [#1811](https://github.com/foosel/OctoPrint/issues/1811) - Slight rewording and rearrangement in timelapse configuration, better feedback if settings have been saved. - * [#1818](https://github.com/foosel/OctoPrint/issues/1818) - Support both Marlin/Repetier and Smoothieware interpretations of `G90` after an `M83` in GCODE viewer and analysis. Select "G90/G91 overrides relative extruder mode" in Settings > Features for the Smoothieware interpretation. - * [#1858](https://github.com/foosel/OctoPrint/issues/1858) - Announcement plugin: Images from registered feeds now are lazy loading. - * [#1862](https://github.com/foosel/OctoPrint/issues/1862) - Automatically re-enable fancy terminal functionality when performance recovers. - * [#1875](https://github.com/foosel/OctoPrint/issues/1875) - Marked the command input field in the Terminal tab as not supporting autocomplete to work around an issue in Safari. Note that this is probably only a temporary workaround due to browser vendors [working on deprecating `autocomplete="off"` support](https://bugs.chromium.org/p/chromium/issues/detail?id=468153#c164) and a different solution will need to be found in the future. - * Added link to [`SerialException` FAQ entry](https://github.com/foosel/OctoPrint/wiki/FAQ#octoprint-randomly-loses-connection-to-the-printer-with-a-serialexception) to terminal output when such an error is encountered, as suggested in [#1876](https://github.com/foosel/OctoPrint/issues/1876). - * Force refresh of settings on login/logout. - * Made system wide API key management mirror user API key management. - * Make sure to always migrate and merge saved printer profiles with default profile to ensure all parameters are set. Should avoid issues with plugins trying to save outdated/incomplete profiles. - * Added note on lack of language pack repository & to use the wiki for now. - * Earlier validation of file to select for printing. - * Limit verbosity of failed system event handlers. - * Made bundled python client `octoprint_client` support multiple client instances. - * Disable "Reload" button in the "Please reload" overlay once clicked, added spinner. - * Updated pnotify to 2.1.0. - * Get rid of ridiculous float precision in temperature commands. - * Detect invalid settings data to persist (not a dict), send 400 status in such a case. - * More logging for preemptive caching, to help narrow down any performance issues that might be related to this. - * Further decoupling of some startup tasks from initial server startup thread for better parallelization and improved startup times. - * Announcement plugin: Added combined OctoBlog feed, replacing news and spotlight feed, added corresponding config migration. - * Announcement plugin: Subscribe to all registered feeds by default to ensure better communication flow (all subscriptions but the "Important" channel can however be unsubscribed easily, added corresponding note to the notifications and also a configuration button to the announcement reader). - * Announcement plugin: Auto-hide announcements on logout. - * Announcement plugin: Order channels server-side based on new order config setting. - * Plugin manager: Show warning when disabling a bundled plugin that is not recommended to be disabled, including a reason why disabling it is not recommended. Applies to the bundled announcement, core wizard, discovery and software update plugins. - * Plugin manager: Support for plugin notices for specific plugins from the plugin repository, e.g. to inform users of specific plugins about known issues with the plugin or instruct to update when the software update mechanism of the current plugin version turns out to be misconfigured. Supports matching installed plugin versions and OctoPrint versions to target only affected users. - * Plugin manager: Better visualization of plugins disabled on the repository, no longer shown as "incompatible" but "disabled", with link to the plugin repository page that contains more information. - * Plugin manager: Detect necessity to reinstall a plugin provided through archive URL or upload and immediately do that instead of reporting an "unknown error" without further information. - * Plugin manager: Added `freebsd` for compatibility check. - * Plugin manager: More general flexibility for OS compatibility check: - * Support for arbitrary values to match against - * Allow 1:1 check again `sys.platform` values (with `startswith`). - * Support black listing (`!windows`) additionally to white listing. A detected OS must match all provided white list elements (if the white list is empty that is considered to be always the case) and none of the black list elements (if the black list is empty that is also considered to be always the case). - * Software update plugin: New check type `bitbucket_commit` (see also [#1898](https://github.com/foosel/OctoPrint/pull/1898)) - * Docs: Now referring to dedicated Jinja 2.8 documentation as hosted at [jinja.octoprint.org](http://jinja.octoprint.org) for all template needs, to avoid confusion when consulting current Jinja documentation as available on its project page (2.9+, which OctoPrint can't upgrade to due to backwards incompatible changes). - * Docs: Better documentation of what kind of input the `FileManager` accepts for `select_file`. - * Docs: Specified OctoPrint version required for plugin tutorial. - -### Bug fixes - - * [#202](https://github.com/foosel/OctoPrint/issues/202) - Fixed an issue with the drag-n-drop area flickering if the mouse was moved too slow while dragging (see also [#1867](https://github.com/foosel/OctoPrint/pull/1867)). - * [#1671](https://github.com/foosel/OctoPrint/issues/1671) - Removed obsolete entry of no longer available filter for empty folders from file list options. - * [#1821](https://github.com/foosel/OctoPrint/issues/1821) - Properly reset "Capture post roll images" setting in timelapse configuration when switching from "off" to "timed" timelapse mode. - * [#1822](https://github.com/foosel/OctoPrint/issues/1822) - Properly reset file metadata when a file is overwritten with a new version. - * [#1836](https://github.com/foosel/OctoPrint/issues/1836) - Fixed order of `PrintCancelled` and `PrintFailed` events on print cancel. - * [#1837](https://github.com/foosel/OctoPrint/issues/1837) - Fixed a race condition causing OctoPrint trying to read data from the current job on job cancel that was no longer there. - * [#1838](https://github.com/foosel/OctoPrint/issues/1838) - Fixed a rare race condition causing an error right at the very start of a print. - * [#1863](https://github.com/foosel/OctoPrint/issues/1863) - Fixed an issue in the analysis of GCODE files containing coordinate offsets for X, Y or Z via `G92`, leading to a wrong calculation of the model size thanks to accumulating offsets. - * [#1882](https://github.com/foosel/OctoPrint/issues/1882) - Fixed a rare race condition occurring at the start of streaming a file to the printer's SD card, leading to endless line number mismatches. - * [#1884](https://github.com/foosel/OctoPrint/issues/1884) - CuraEngine plugin: Fixed a potential encoding issue when logging non-ASCII parameters supplied to CuraEngine - * [#1891](https://github.com/foosel/OctoPrint/issues/1891) - Fixed error when handling unicode passwords. - * [#1893](https://github.com/foosel/OctoPrint/issues/1893) - CuraEngine plugin: Fixed handling of multiple consecutive uploads of slicing profiles (see also [#1894](https://github.com/foosel/OctoPrint/issues/1894)) - * [#1897](https://github.com/foosel/OctoPrint/issues/1897) - Removed possibility to concurrently try to perform multiple tests of the configured snapshot URL. - * [#1906](https://github.com/foosel/OctoPrint/issues/1906) - Fixed interpretation of `G92` in GCODE analysis. - * [#1907](https://github.com/foosel/OctoPrint/issues/1907) - Don't send temperature commands with tool parameter when a shared nozzle is defined. - * [#1917](https://github.com/foosel/OctoPrint/issues/1917) (regression) - Fix job data resetting on print job completion. - * [#1918](https://github.com/foosel/OctoPrint/issues/1918) (regression) - Fix "save as default" checkbox not being disabled when other controls are disabled. - * [#1919](https://github.com/foosel/OctoPrint/issues/1919) (regression) - Fix call to no longer existing function in Plugin Manager UI. - * [#1934](https://github.com/foosel/OctoPrint/issues/1934) (regression) - Fix consecutive timed timelapse captures without configured post roll. - * Fixed API key QR Code being shown (for "n/a" value) when no API key was set. - * Fixed timelapse configuration API not returning 400 status code on some bad parameters. - * Fixed a typo (see also [#1826](https://github.com/foosel/OctoPrint/pull/1826)). - * Fixed `filter` and `force` parameters on `/api/files/`. - * Fixed message catchall `*` not working in the socket client library. - * Fixed analysis backlog calculation for sub folders. - * Fixed `PrinterInterface.is_ready` to behave as documented. - * Use black listing instead of white listing again to detect if the `daemon` sub command is supported or not. Should resolve issues users of FreeBSD and the like where having with `octoprint daemon`. - * Use `pip` instead of `python setup.py develop` in `octoprint dev plugin:install` command to avoid issues on Windows. - * Docs: Fixed a wrong command in the plugin tutorial (see also [#1860](https://github.com/foosel/OctoPrint/pull/1860)). - -### More information - -- [Commits](https://github.com/foosel/OctoPrint/compare/1.3.2...1.3.3) -- Release Candidates: - - [1.3.3rc1](https://github.com/foosel/OctoPrint/releases/tag/1.3.3rc1) - - [1.3.3rc2](https://github.com/foosel/OctoPrint/releases/tag/1.3.3rc2) - - [1.3.3rc3](https://github.com/foosel/OctoPrint/releases/tag/1.3.3rc3) - -## 1.3.2 (2017-03-16) - -### Note for plugin authors - -**If you maintain a plugin that extends OctoPrint's [JavaScript Client Library](http://docs.octoprint.org/en/master/jsclientlib/index.html)** like demonstrated in e.g. the bundled Software Update Plugin you'll need to update the way you register your plugin to depend on `OctoPrintClient` and registering your extension as shown [here](https://github.com/foosel/OctoPrint/blob/6e793c2/src/octoprint/plugins/softwareupdate/static/js/softwareupdate.js#L1-L84) instead of directly writing to `OctoPrint.plugins` (like it was still done [here](https://github.com/foosel/OctoPrint/blob/23744cd/src/octoprint/plugins/softwareupdate/static/js/softwareupdate.js#L1-L81)). That way your extensions will be available on all instances of `OctoPrintClient`, not just the global instance `OctoPrint` that gets created on startup of the core web interface. - -If all you plugin does with regards to JavaScript is registering a custom view model and you have no idea what I'm talking about regarding extending the client library, no action is necessary. This heads-up is really only relevant if you extended the JavaScript Client Library. - -### Improvements - -- [#732](https://github.com/foosel/OctoPrint/pull/732) - Have OctoPrint's `python setup.py clean` build on stock -`python setup.py clean` for better compatibility with packaging systems -- [#1506](https://github.com/foosel/OctoPrint/issues/1506) - Better handle really long "dwell"/`G4` commands on Repetier firmware (as for example apparently recommended to use with Wanhao D6 and similar printers for nozzle cooling) by actively stalling communication from OctoPrint's side as well. That way we no longer run into a communication timeout produced by a 5min dwell immediately happily acknowledged by the printer with an `ok`. -- [#1542](https://github.com/foosel/OctoPrint/issues/1542) - Support for multi-extruder setups with a shared single nozzle and heater (e.g. E3D Cyclops, Diamond hotend, etc). -- [#1676](https://github.com/foosel/OctoPrint/issues/1676) - Trigger line number reset when connected to printer and seeing `start` message. This should fix issues with printer communication when printer resets but reset goes otherwise undetected. -- [#1681](https://github.com/foosel/OctoPrint/issues/1681) - Support for connecting to multiple OctoPrint instances via the [JavaScript Client Library](http://docs.octoprint.org/en/master/jsclientlib/index.html). -- [#1712](https://github.com/foosel/OctoPrint/issues/1712) - Display current folder name in file list if in sub folder. -- [#1723](https://github.com/foosel/OctoPrint/issues/1723) - Ignore leading `v` or `V` on plugin version numbers for version checks in plugin manager and software updater (see also [#1724](https://github.com/foosel/OctoPrint/pull/1724)) -- [#1770](https://github.com/foosel/OctoPrint/issues/1770) - Better resilience against null bytes received from the printer for whatever reason. -- [#1770](https://github.com/foosel/OctoPrint/issues/1770) - Detect printer as connected even when only receiving a `wait` instead of `ok`. Should solve issues with initial connect where printer sends garbage over the line that eats/covers the `ok` if printer also sends regular `wait` messages when idle. -- [#1780](https://github.com/foosel/OctoPrint/issues/1780) - Work around for Safari re-opening one copy of the webcam stream after the other and eating up bandwidth unnecessarily (see also [#1786](https://github.com/foosel/OctoPrint/issues/1786)) -- [#1790](https://github.com/foosel/OctoPrint/issues/1790) - Removed unused "color" property from printer profile editor. -- [#1805](https://github.com/foosel/OctoPrint/issues/1805) - Better error resilience against invalid print history data from plugins that replace the printer communication. -- Better error resilience in Plugin Manager against wonky version data in repository file. -- Added a "Restart in safe mode" system menu entry that will always be available if the restart command is configured -- CLI: Only offer `daemon` sub command on Linux (since that it's the only OS it works on) -- Less throttling of analysis of GCODE files from the analysis backlog. Should still leave Pi and friends air to breathe but allow a slightly faster processing of the backlog. -- Added an explanation of safe mode to the docs. -- Log OctoPrint version & plugin list when detecting log rollover. -- Allow `UiPlugin`s to define additional fields for ETag generation. -- Allow `UiPlugin`s utilizing OctoPrint's stock templates to filter out what they don't need. -- Locales contained in `translations` of plugins will now be registered with the system. That way it's possible to provide translations for the full application through plugins. -- Abort file analysis if file is about to be overwritten -- Software Update Plugin: Refresh cache on startup, prevent concurrent refresh -- More solid parsing of request line number for resend requests. Should improve compatibility with Teacup firmwares. Based on issue reported via PR [#300](https://github.com/foosel/OctoPrint/pull/300) - -### Bug fixes - -- [#733](https://github.com/foosel/OctoPrint/issues/733) - Fixed multiple event handler commands running concurrently. Now they run one after the other, as expected. -- [#1317](https://github.com/foosel/OctoPrint/issues/1317) - Fixed a color distortion issue when rendering timelapses from higher resolution source snapshots that also need to be rotated by adjusting `ffmpeg` parameters to avoid an unexpected behaviour when a pixel format and a filter chain are required for processing. -- [#1560](https://github.com/foosel/OctoPrint/issues/1560) - Make sure we don't try to use an empty `logging.yaml` -- [#1631](https://github.com/foosel/OctoPrint/issues/1631) - Disable "Slice" button in slice dialog if a print is ongoing and a slicer is selected that runs on the same device as OctoPrint. The server would already deny such requests (simply due to performance reasons), but the UI didn't properly reflect that yet. -- [#1671](https://github.com/foosel/OctoPrint/issues/1671) - Removed "Hide empty folders" option from file list. Didn't really add value and caused usability issues. -- [#1771](https://github.com/foosel/OctoPrint/issues/1771) - Fixed `_config_version` in plugin settings not being properly updated. -- [#1732](https://github.com/foosel/OctoPrint/issues/1732) - Fixed a bug in the documentation for the printer profile API -- [#1760](https://github.com/foosel/OctoPrint/issues/1760) - Fixed missing reselect of selected file when updating via the watched folder, causing wrong progress percentages to be reported. -- [#1765](https://github.com/foosel/OctoPrint/issues/1765) - Fixed watched folder not waiting with file move until file stopped growing, causing wrong progress percentages to be reported. -- [#1777](https://github.com/foosel/OctoPrint/issues/1777) - Fixed z-change based timelapses with Slic3r generated z-hops not properly triggering snapshots. -- [#1792](https://github.com/foosel/OctoPrint/issues/1792) - Don't tell Safari we are "web-app-capable" because that means it will throw away all cookies and the user will have to constantly log in again when using a desktop shortcut for the OctoPrint instance. -- [#1812](https://github.com/foosel/OctoPrint/issues/1812) - Don't scroll up navigation in settings when switching between settings screens, very annoying on smaller resolutions (see also [#1814](https://github.com/foosel/OctoPrint/pull/1814)) -- Fix settings helper not allowing to delete values for keys that are present in the local config but not in the defaults. -- Fix wrong replacement value for `__progress` in registered command line or GCODE [event handlers](http://docs.octoprint.org/en/master/events/index.html). -- Various fixes in the Software Update Plugin: - - Don't remove manual software update configurations on settings migration. - - Properly delete old restart/reboot commands that are now defined globally since config version 4. An issue with the settings helper prevented us from properly deleting them during migration to version 4. - - Fixed `python_checker` version check method and `python_updater` update method. - - Fixed update configs without a restart of any kind causing an issue due to an undefined variable. - - Fixed broken doctests. -- Upgrade LESS.min.js from 2.7.1 to 2.7.2 to fix the broken contrast function -- Always create a new user session for requests with an API key -- Fixed an error when reading all user settings via the API -- Fixed a bunch of caching issues for the page, was not properly updated on change of snapshot URL presence, system menu entry presence, gcode viewer enabled/disabled, changes in access control availability. -- Fixed wrong bundling of core and plugin assets -- Software Update Plugin: Fixed wrong ETag calculation -- Disable external heatup detection until firmware is detected -- Fixed login dropdown not closing on click outside of it -- Fixed new user settings getting lost until restart -- Don't call `onUserLoggedIn`/`onUserLoggedOut` on user reload - -### More information - -- [Commits](https://github.com/foosel/OctoPrint/compare/1.3.1...1.3.2) -- Release Candidates: - - [1.3.2rc1](https://github.com/foosel/OctoPrint/releases/tag/1.3.2rc1) - -## 1.3.1 (2017-01-25) - -### Note for upgraders - -#### If you installed OctoPrint manually and used the included init script, you need to update that - -The init script so far shipped with OctoPrint contained a [bug](https://github.com/foosel/OctoPrint/issues/1657) that causes issues with OctoPrint 1.3.0 and higher. Please update your init script to the fixed version OctoPrint now ships under `scripts`: - -``` -sudo cp /path/to/OctoPrint/scripts/octoprint.init /etc/init.d/octoprint -``` - -If you are running OctoPi, this does **not** apply to you and you do not need to do anything here! - -#### Change in stock terminal filter configuration - -1.3.1 fixes an issue with the two terminal filters for suppressing temperature and SD status messages and adds a new filter for filtering out firmware `wait` messages. These changes will only be active automatically though for stock terminal filter configurations. If you have customized your terminal filters, you'll need to apply these changes manually under "Settings > Terminal filters": -- Changed "Suppress temperature messages" filter, new regex is `(Send: (N\d+\s+)?M105)|(Recv: ok (B|T\d*):)` -- Changed "Suppress SD status messages" filter, new regex is `(Send: (N\d+\s+)?M27)|(Recv: SD printing byte)` -- New "Suppress wait responses" filter, regex is `Recv: wait` - -### Improvements -- [#1607](https://github.com/foosel/OctoPrint/issues/1607) - Way better support for password managers (e.g. browser built-in, 1Password, Lastpass, Dashlane) -- [#1638](https://github.com/foosel/OctoPrint/issues/1638) - Make confirmation dialog when cancelling a print optional. -- [#1656](https://github.com/foosel/OctoPrint/issues/1656) - Make wording of buttons on print cancel dialog less confusing. -- [#1705](https://github.com/foosel/OctoPrint/pull/1705) - Simplified install process on Mac by removing dependency on pyobjc. -- [#1706](https://github.com/foosel/OctoPrint/pull/1706) - Added a mask icon for Safari pinned tab and touchbar. -- Support extraction of filament diameter for volume calculation from GCODE files sliced through Simplify3D. -- Abort low priority jobs in the file analysis queue when a high priority job comes in - should make file analysis and hence time estimates show up faster for newly uploaded files. -- Added a terminal filter for firmware `wait` messages to the stock terminal filters. If you did modify your terminal filter configuration, you might want to add this manually: - - New "Suppress wait responses" filter: `Recv: wait` - -### Bug fixes - -- [#1344](https://github.com/foosel/OctoPrint/issues/1344) - Fix ProgressBarPlugins to not correctly be triggered for 0% (second try, this time hopefully for good). -- [#1637](https://github.com/foosel/OctoPrint/issues/1637) - Fix issue preventing a folder to be deleted that has a name which is a common prefix of the file currently being printed. -- [#1641](https://github.com/foosel/OctoPrint/issues/1641) - Fix issue with `octoprint --daemon` not working. -- [#1647](https://github.com/foosel/OctoPrint/issues/1647) - Fix issue with `octoprint` command throwing an error if an environment variable `OCTOPRINT_VERSION` was set to its version number. -- [#1648](https://github.com/foosel/OctoPrint/issues/1648) - Added missing `websocket-client` dependency of `octoprint client` to install script. -- [#1653](https://github.com/foosel/OctoPrint/issues/1653) - Fix for an issue with the included init script on the BBB (see also [#1654](https://github.com/foosel/OctoPrint/issues/1654)) -- [#1657](https://github.com/foosel/OctoPrint/issues/1657) - Fix init script regarding check for configured `CONFIGFILE` variable. -- [#1657](https://github.com/foosel/OctoPrint/issues/1657) - Don't care about ordering of common parameters (like `--basedir`, `--config`) on CLI. -- [#1660](https://github.com/foosel/OctoPrint/issues/1660) - Do not show hint regarding keyboard controls beneath webcam stream if keyboard control feature is disabled. -- [#1667](https://github.com/foosel/OctoPrint/issues/1667) - Fix for matching folders not getting listed in the results when performing a search in the file list. -- [#1675](https://github.com/foosel/OctoPrint/issues/1675) - Fix model size calculation in GCODE analysis, produced wrong values in some cases. Also adjusted calculation to match implementation in GCODE viewer, now both produce identical results. -- [#1685](https://github.com/foosel/OctoPrint/issues/1685) - Cura Plugin: Fix filament extraction from CuraEngine slicing output -- [#1692](https://github.com/foosel/OctoPrint/issues/1692) - Cura Plugin: Fix solid layer calculation (backport from [Ultimaker/CuraEngine#140](https://github.com/Ultimaker/CuraEngine/issues/140)) -- [#1693](https://github.com/foosel/OctoPrint/issues/1693) - Cura Plugin: Support `perimeter_before_infill` profile setting. Additionally added support for `solidarea_speed`, `raft_airgap_all`, `raft_surface_thickness`, `raft_surface_linewidth` profile settings and adjusted mapping for engine settings `raftAirGapLayer0`, `raftFanSpeed`, `raftSurfaceThickness`and `raftSurfaceLinewidth` according to current mapping in Cura Legacy and adjusted Mach3 GCODE flavor to substitute `S` with `P` in temperature commands of generated start code, also like in Cura Legacy. -- [#1697](https://github.com/foosel/OctoPrint/issues/1697) - Pin Jinja to versions <2.9 for now due to a backwards compatibility issue with templates for versions newer than that. Also pushed as a hotfix to 1.3.0 (as 1.3.0post1). -- [#1708](https://github.com/foosel/OctoPrint/issues/1708) - Cura Plugin: Fixed selection of `start.gcode` for sliced file -- Allow a retraction z-hop of 0 in timelapse configuration. -- Fix files in sub folders to not be processed by the initial analysis backlog check during startup of the server. -- Various fixes in the file analysis queue: - - High priority items are now really high priority - - Abort analysis for items that are to be deleted/moved to get around an issue with file access under Windows systems. -- Fix stock terminal filters for suppressing temperature messages and SD status messages to also be able to deal with line number prefixes. If you have added additional terminal filters, you will have to apply this fix manually: - - Changed "Suppress temperature messages" filter: `(Send: (N\d+\s+)?M105)|(Recv: ok (B|T\d*):)` - - Changed "Suppress SD status messages" filter: `(Send: (N\d+\s+)?M27)|(Recv: SD printing byte)` -- Fix issue in german translation. - -### More information - -- [Commits](https://github.com/foosel/OctoPrint/compare/1.3.0...1.3.1) -- Release Candidates: - - [1.3.1rc1](https://github.com/foosel/OctoPrint/releases/tag/1.3.1rc1) - - [1.3.1rc2](https://github.com/foosel/OctoPrint/releases/tag/1.3.1rc2) - -## 1.3.0 (2016-12-08) - -### Features - - * You can now create folders in the file list, upload files into said folders and thus better manage your projects' files. - * New wizard dialog for system setups that can also be extended by plugins. Replaces the first run dialog for setting up access control and can also be triggered in other cases than only the first run, e.g. if plugins necessitate user input to function properly. Added wizards to help configuring the following components in OctoPrint on first run: access control, webcam URLs & ffmpeg path, server commands (restart, shutdown, reboot), printer profile. Also extended the bundled Cura plugin to add a wizard for its first setup to adjust path and import a slicing profile, and the bundled Software Update plugin to ask the user for details regarding the OctoPrint update configuration. Also see below. - * New command line interface (CLI). Offers the same functionality as the old one plus: - * a built-in API client (``octoprint client --help``) - * built-in development tools (``octoprint dev --help``) - * extendable through plugins implementing the ``octoprint.cli.commands`` hook (``octoprint plugins --help``) - * New features within the plugin system: - * Plugins may now give hints in which order various hooks or mixin methods should be called by optionally providing an integer value that will be used for sorting the callbacks prior to execution. - * Plugins may now define configuration overlays to be applied on top of the default configuration but before ``config.yaml``. - * New mixin `UiPlugin` for plugins that want to provide an alternative web interface delivered by the server. - * New mixin ``WizardPlugin`` for plugins that want to provide wizard components to OctoPrint's new wizard dialog. - * New hook ``octoprint.cli.commands`` for registering a command with the new OctoPrint CLI - * New hook ``octoprint.comm.protocol.gcode.received`` for receiving messages from the printer - * New hook ``octoprint.printer.factory`` for providing a custom factory to contruct the global ``PrinterInterface`` implementation. - * New ``TemplatePlugin`` template type: ``wizard`` - * New Javascript client library for utilizing the server's API, can be reused by `UiPlugin`s. - * OctoPrint will now track the current print head position on pause and cancel and provide it as new template variables ``pause_position``/``cancel_position`` for the relevant GCODE scripts. This will allow more intelligent pause codes that park the print head at a rest position during pause and move it back to the exact position it was before the pause on resume ([Example](https://gist.github.com/foosel/1c09e269b1c0bb7a471c20eef50c8d3e)). Note that this is NOT enabled by default and for now will necessitate adjusting the pause and resume GCODE scripts yourself since position tracking with multiple extruders or when printing from SD is currently not fool proof thanks to firmware limitations regarding reliable tracking of the various ``E`` values and the currently selected tool ``T``. In order to fully implement this feature, the following improvements were also done: - * New ``PositionUpdated`` event when OctoPrint receives a response to an ``M114`` position query. - * Extended ``PrintPaused`` and ``PrintCancelled`` events with position data from ``M114`` position query on print interruption/end. - * Added (optional) firmware auto detection. If enabled (which it is by default), OctoPrint will now send an ``M115`` to the printer on initial connection in order to try to figure out what kind of firmware it is. For FIRMWARE_NAME values containing "repetier" (case insensitive), all Repetier-specific flags will be set on the comm layer. For FIRMWARE_NAME values containing "reprapfirmware" (also case insensitive), all RepRapFirmware-specific flags will be set on the comm layer. For now no other handling will be performed. - * Added safe mode flag ``--safe`` and config setting ``startOnceInSafeMode`` that disables all third party plugins when active. The config setting will automatically be removed from `config.yaml` after the server has started through successfully. - * Added ``octoprint config`` CLI that allows easier manipulation of config.yaml entries from the command line. Example: ``octoprint config set --bool server.startOnceInSafeMode true`` - -### Improvements - - * [#1048](https://github.com/foosel/OctoPrint/issues/1048) - Added "Last print time" to extended file information (see also [#1522](https://github.com/foosel/OctoPrint/pull/1522)) - * [#1422](https://github.com/foosel/OctoPrint/issues/1422) - Added option for post roll for timed timelapse to duplicate last frame instead of capturing new frames. That makes for a faster render at the cost of a still frame at the end of the rendered video. See also [#1553](https://github.com/foosel/OctoPrint/pull/1553). - * [#1551](https://github.com/foosel/OctoPrint/issues/1551) - Allow to define a custom bounding box for valid printer head movements in the printer profile, to make print dimension check more flexible. - * [#1583](https://github.com/foosel/OctoPrint/pull/1583) - Strip invalid `pip` arguments from `pip uninstall` commands, if provided by the user as additional pip arguments. - * [#1593](https://github.com/foosel/OctoPrint/issues/1593) - Automatically migrate old manual system commands for restarting OctoPrint and rebooting or shutting down the server to the new system wide configuration settings. Make a backup copy of the old `config.yaml` before doing so in case a manual rollback is required. - * New central configuration option for commands to restart OctoPrint and to restart and shut down the system OctoPrint is running on. This allows plugins (like the Software Update Plugin or the Plugin Manager) and core functionality to perform these common administrative tasks without the user needing to define everything redundantly. - * `pip` helper now adjusts `pip install` parameters corresponding to detected `pip` version: - * Removes `--process-dependency-links` when it's not needed - * Adds `--no-use-wheel` when it's needed - * Detects and reports on completely broken versions - * Better tracking of printer connection state for plugins and scripts: - * Introduced three new Events `Connecting`, `Disconnecting` and `PrinterStateChanged`. - * Introduced new GCODE script `beforePrinterDisconnected` which will get sent before a (controlled) disconnect from the printer. This can be used to send some final commands to the printer before the connection goes down, e.g. `M117 Bye from OctoPrint`. - * The communication layer will now wait for the send queue to be fully processed before disconnecting from the printer for good. This way it is ensured that the `beforePrinterDisconnected` script or any further GCODE injected into it will actually get sent. - * Additional baud rates to allow for connecting can now be specified along side additional serial ports via the settings dialog and the configuration file. - * Option to never send checksums (e.g. if the printer firmware doesn't support it), see [#949](https://github.com/foosel/OctoPrint/issues/949). - * Added secondary temperature polling interval to use when printer is not printing but a target temperature is set - this way the graph should be more responsive while monitoring a manual heatup. - * Test buttons for webcam snapshot & stream URL, ffmpeg path and some other settings (see also [#183](https://github.com/foosel/OctoPrint/issues/183)). - * Temperature graph automatically adjusts its Y axis range if necessary to accomodate the plotted data (see also [#632](https://github.com/foosel/OctoPrint/issues/632)). - * "Fan on" command now always sends `S255` parameter for better compatibility across firmwares. - * Warn users with a notification if a file is selected for printing that exceeds the current print volume (if the corresponding model data is available, see also [#1254](https://github.com/foosel/OctoPrint/pull/1254)) - * Added option to also display temperatures in Fahrenheit (see also [#1258] (https://github.com/foosel/OctoPrint/pull/1258)) - * Better error message when the ``config.yaml`` file is invalid during startup - * API now also allows issuing absolute jogging commands to the printer - * Printer profile editor dialog refactored to better structure fields and explain where they are used - * Option to detect z-hops during z-based timelapses and not trigger a snapshot (see also [1148](https://github.com/foosel/OctoPrint/pull/1148)) - * File rename, move and copy functionality exposed via API, not yet utilized in stock frontend but available in [file manager plugin](https://github.com/Salandora/OctoPrint-FileManager). - * Try to assure a sound SSL environment for the process at all times - * Improved caching: - * Main page and asset files now carry proper ``ETag`` and ``Last-Modified`` headers to allow for sensible browser-side caching - * API sets ``Etag`` and/or ``Last-Modified`` headers on responses to GET requests where possible and feasible to allow for sensible browser-side caching - * Renamed ``GcodeFilesViewModel`` to ``FilesViewModel`` - plugin authors should accordingly update their dependencies from ``gcodeFilesViewModel`` to ``filesViewModel``. Using the old name still works, but will log a warning and stop working with 1.4.x. - * Make sure ``volume.depth`` for circular beds is forced to ``volume.width`` in printer profiles - * Support for `M116` - * Support ``M114`` responses without whitespace between coordinates (protocol consistency - who needs it?). - * `M600` is now marked as a long running command by default. - * Don't focus files in the file list after deleting a file - made the list too jumpy. - * Cura plugin: "Test" button to check if path to cura engine is valid. - * Cura plugin: Wizard component for configuring the path to the CuraEngine binary and for importing the first slicing profile - * GCODE viewer: Added Layer Up/Down buttons (see also [#1306] (https://github.com/foosel/OctoPrint/pull/1306)) - * GCODE viewer: Allow cycling through layer via keyboard (up, down, pgup, pgdown) - * GCODE viewer: Allow changing size thresholds via settings menu (see also [#1308](https://github.com/foosel/OctoPrint/pull/1308)) - * GCODE viewer: Added support for GCODE arc commands (see also [#1382](https://github.com/foosel/OctoPrint/pull/1382)) - * Language packs: Limit upload dialog for language pack archives to .zip, .tar.gz, .tgz and .tar extensions. - * Plugin Manager: Adjusted to utilize new `pip` helper - * Plugin Manager: Show restart button on install/uninstall notification if restart command is configured and a restart is required - * Plugin Manager: Track managable vs not managable plugins - * Plugin Manager: Allow hiding plugins from Plugin Manager via ``config.yaml``. - * Plugin Manager: Limit upload dialog for plugin archives to .zip, .tar.gz, .tgz and .tar extensions. - * Plugin Manager: Allow closing of all notifications and close them automatically on detected server disconnect. No need to keep a "Restart needed" message around if a restart is in progress. - * Software Update plugin: More verbose output for logged in administrators. Will now log the update commands and their output similar to the Plugin Manager install and uninstall dialog. - * Software Update plugin: CLI for checking for and applying updates - * Software Update plugin: Wizard component for configuring OctoPrint's update mechanism - * Software Update plugin: "busy" spinner on check buttons while already checking for updates. - * Software Update plugin: Prevent update notification when wizard is open. - * Plugin Manager / Software Update plugin: The "There's a new version of pip" message during plugin installs and software updates is no longer displayed like an error. - * Plugin Manager / Software Update plugin: The "busy" dialog can no longer be closed accidentally. - * Timelapse: Better (& earlier) reporting to the user when something's up with the snapshot URL causing issues with capturing timelapse frames and hence making it impossible to render a timelapse movie on print completion. - * Virtual printer: Usage screen for the ``!!DEBUG`` commands on ``!!DEBUG``, ``!!DEBUG:help`` or ``!!DEBUG:?`` - * Updated frontend dependencies (possibly relevant for plugin authors): - * Bootstrap to 2.3.2 - * JQuery to 2.2.4 - * Lodash to 3.10.1 - * SockJS to 1.1.1 - * Better error resilience against errors in UI components. - * Various improvements in the GCODE interpreter which performs the GCODE analysis - * Various adjustments towards Python 3 compatibility (still a work in progress though, see also [#1411](https://github.com/foosel/OctoPrint/pull/1411), [#1412](https://github.com/foosel/OctoPrint/pull/1412), [#1413](https://github.com/foosel/OctoPrint/pull/1413), [#1414](https://github.com/foosel/OctoPrint/pull/1414)) - * Various code refactorings - * Various small UI improvements - * Various documentation improvements - -### Bug fixes - - * [#1047](https://github.com/foosel/OctoPrint/issues/1047) - Fixed 90 degree webcam rotation for iOS Safari. - * [#1148](https://github.com/foosel/OctoPrint/issues/1148) - Fixed retraction z hop setting for Z-triggered timelapses. Was not correctly propagated to the backend and hence not taking effect. - * [#1567](https://github.com/foosel/OctoPrint/issues/1567) - Invalidate ``/api/settings`` cache on change of the user's login state (include user roles into ETag calculation). - * [#1586](https://github.com/foosel/OctoPrint/issues/1586) - Fixed incompatibility of update script and command line helper with non-ASCII output from called commands. - * [#1588](https://github.com/foosel/OctoPrint/issues/1588) - Fixed feedback controls again. - * [#1599](https://github.com/foosel/OctoPrint/issues/1599) - Properly handle exceptions that arise within the update script during runtime. - * It's not possible anymore to select files that are not machinecode files (e.g. GCODE) for printing on the file API. - * Changes to a user's personal settings via the UI now propagate across sessions. - * Improved compatibility of webcam rotation CSS across newer browsers (see also [#1436](https://github.com/foosel/OctoPrint/pull/1436)) - * Fix for system menu not getting properly reloaded after entries changed - * Invalidate ``/api/settings`` cache on change of the currently enabled plugins (by including plugin names into ETag calculation) and/or on change of the current effective config. - * Fix for `/api/settings` not being properly invalidated for plugin settings that do not have a representation in `config.yaml` but are only added at runtime (and hence are not captured by `config.effective`). - * Invalidate ``/api/timelapse`` cache on change of the current timelapse configuration. - * Fixed an issue causing the version number not to be properly extracted from ``sdist`` tarballs generated under Windows. - * Get rid of double scroll bar in printer profile editor. - * Fixed tracking of current byte position in file being streamed from disk. Turns out that ``self._handle.tell`` lied to us thanks to line buffering. - * Fixed select & print not working anymore for SD files thanks to a timing issue. - * Fixed ``PrintFailed`` event payload (was still missing new folder relevant data). - * Fixed premature parse stop on ``M114`` and ``M115`` responses with ``ok``-prefix. - * Make sure `?l10n` request parameter gets also propagated to API calls as `X-Locale` header in case of locale sensitive API responses. - * Fix language mixture due to cached template configs including localized strings; cache per locale. - * Only insert divider in system menu after core commands if there are custom commands. - * Fix update of webcam stream URL not being applied due to caching. - * Fixed a rare race condition causing the new "The settings changed, reload?" popup to show up even when the settings change originated in the same client instance. - * Fixed a bunch of missing translations. - * Pinned Tornado version to 4.0.2. Former version requirement was able to pull in a beta version causing issues with websockets due to a bug in `permessage-deflate` handling. The Tornado requirement needs an update, but we'll leave it at 4.0.2 for 1.3.0 since we'll need to do some migration work for compatibility with anything newer. Potentially related to [#1523](https://github.com/foosel/OctoPrint/issues/1523). - * Fix a rare race condition in the command line helper and the update script that could cause the code to hang due to waiting on an event that would never be set. - * Fix issue with handling new settings substructures when they are compared to existing settings data in order to find the structural diff. - * Fix for the temperature graph not displaying the data history on site reload. - -### More information - - * [Commits](https://github.com/foosel/OctoPrint/compare/1.2.18...1.3.0) - * Release Candidates: - * [1.3.0rc1](https://github.com/foosel/OctoPrint/releases/tag/1.3.0rc1) - * [1.3.0rc2](https://github.com/foosel/OctoPrint/releases/tag/1.3.0rc2) - * [1.3.0rc3](https://github.com/foosel/OctoPrint/releases/tag/1.3.0rc3) - -## 1.2.18 (2016-11-30) - -### Improvements - - * Allow arbitrary frame rates for creating timelapses. Before, the entered fps value was also directly used as frame rate for the actual video, which could cause problems with any frame rates not specified in the MPEG2 standard. Now OctoPrint will use a standard frame rate for the rendered video and render the timelapse stills into the finished movie with the configured frame rate. - * Limited Cura profile importer to `.ini` files and clarified the supported versions - * Add support for the `R` parameter for `M109` and `M190` - -### Bug fixes - - * [#1541](https://github.com/foosel/OctoPrint/issues/1541) - Fix selecting the printer profile to use by default - * [#1543](https://github.com/foosel/OctoPrint/issues/1543) - Fix target temperature propagation from communication layer - * [#1567](https://github.com/foosel/OctoPrint/issues/1567) - Fix issue with restricted settings getting parsed to the wrong data structure in the frontend if loaded anonymously first. - * [#1571](https://github.com/foosel/OctoPrint/issues/1571) - Fix parsing of port number from HTTP Host header for IPv6 addresses - * Fix issue with settings restriction causing internal settings defaults to be changed. - -### More information - - * [Commits](https://github.com/foosel/OctoPrint/compare/1.2.17...1.2.18) - * Release Candidates: - * [1.2.18rc1](https://github.com/foosel/OctoPrint/releases/tag/1.2.18rc1) - -## 1.2.17 (2016-11-08) - -### Improvements - - * Files like `config.yaml` etc will now persist their permissions, with a lower and upper permission bounds for sanitization (e.g. removing executable flags on configuration files but keeping group read/write permissions if found). - * Log full stack trace on socket connection errors when debug logging for `octoprint.server.util.sockjs` is enabled - * ``SettingsPlugin``s may now mark configuration paths as restricted so that they are not returned on the REST API - * Updated LESS.js version - * Improved the `serial.log` logging handler to roll over serial log on new connections to the printer instead of continuously appending to the same file. Please note that `serial.log` is a debugging tool only and should *not* be left enabled unless you are trying to troubleshoot something in your printer communication. - * Split JS/CSS/LESS asset bundles according into asset bundles for core + bundled plugins ("packed_core.{js|css|less}") and third party plugins ("packed_plugins.{js|css|less}"). That will allow the core UI to still function properly even if an installed third party plugin produces invalid JS and therefore causes a parser error for the whole plugin JS file. See [#1544](https://github.com/foosel/OctoPrint/issues/1544) for an example of such a situation. - -### Bug fixes - - * [#1531](https://github.com/foosel/OctoPrint/issues/1531) - Fixed encoding bug in HTTP request processing triggered by content type headers on form data fields - * Fixed forced `config.yaml` save on startup caused by mistakenly assuming that printer parameters were always migrated. - * Fixed issue causing ``remember_me`` cookie not to be deleted properly on logout - * Fixed broken filter toggling on ``ItemListHelper`` class used for various lists throughout the web interface - * Fixed an issue with the preliminary page never reporting that the server is now up if the page generated during preliminary caching had no cache headers set (e.g. because it contained the first run setup wizard) - * Fixed a bug causing the update of OctoPrint to not work under certain circumstances: If 1.2.16 was installed and the settings were *never* saved via the "Settings" dialog's "Save", the update of OctoPrint would fail due to a `KeyError` in the updater. Reason is a renamed property, properly switched to when saving the settings. - * Fixed the logging subsystem to properly clean up after itself. - * Fixed a wrong order in loading JS files on the client introduced in 1.2.17rc2 to make the UI more resilient against broken plugin JS. - * Properly handle empty JS file list from plugins. Solves a 500 on OctoPrint instances without any third party plugins installed generated during web asset bundling introduced in 1.2.17rc2. - -### More information - - * [Commits](https://github.com/foosel/OctoPrint/compare/1.2.16...1.2.17) - * Release Candidates: - * [1.2.17rc1](https://github.com/foosel/OctoPrint/releases/tag/1.2.17rc1) - * [1.2.17rc2](https://github.com/foosel/OctoPrint/releases/tag/1.2.17rc2) - * [1.2.17rc3](https://github.com/foosel/OctoPrint/releases/tag/1.2.17rc3) - * [1.2.17rc4](https://github.com/foosel/OctoPrint/releases/tag/1.2.17rc4) - -## 1.2.16 (2016-09-23) - -### Improvements - - * [#1434](https://github.com/foosel/OctoPrint/issues/1434): Make sure to sanitize any file names in the upload folder that do not match OctoPrint's file name "sanitization standard" automatically when creating a file listing. This should solve issues with UI functionality like selecting a file for printing or deleting a file to not work with files that were uploaded manually to the ``uploads`` folder. As a side note: Please don't do this, use the ``watched`` folder if you want to SCP/FTP/copy files directly to OctoPrint. - * [#1434](https://github.com/foosel/OctoPrint/issues/1434): Allow `[` and `]` in uploaded file names. - * [#1481](https://github.com/foosel/OctoPrint/issues/1481): Bring back non-fuzzy layer time estimates in the GCODE viewer. - * Improved fuzzy print time displays in the frontend. Rounding now takes overall duration into account - durations over a day will be rounded up/down to half days, durations over an hour will be rounded up/down to half hours, durations over 30min will be rounded to 10min segments, durations below 30min will be rounded up or down to the next minute depending on the seconds and finally if we are talking about less than a minute, durations over 30s will return "less than a minute", durations under 30s will return "a couple of seconds". - * Improved intermediary loading page: Don't report server as ready and reload until preliminary caching has been done, IF preliminary caching will be done. - * Added release channels to OctoPrint's bundled Software Update plugin. You will now be able to subscribe to OctoPrint's `maintenance` or `devel` release candidates in addition to stable versions. [Read more about Release Channels on the wiki](https://github.com/foosel/OctoPrint/wiki/Using-Release-Channels). - * Return a "400 Bad Request" instead of a "500 Internal Server Error" if a `multipart/form-data` request (e.g. a file upload) is sent which lacks the `boundary` field. - -### Bug Fixes - - * [#1448](https://github.com/foosel/OctoPrint/issues/1448): Don't "eat" first line of the pause script after a pause triggering `M0` but send it to the printer instead - * [#1477](https://github.com/foosel/OctoPrint/issues/1477): Only report files enqueued for analysis which actually are (as in, don't claim to have queued STL files for GCODE analysis) - * [#1478](https://github.com/foosel/OctoPrint/issues/1478): Don't display inaccurate linear estimate ("6 days remaining") until 30 *minutes* have passed, even if nothing else is available. Potentially related to [#1428](https://github.com/foosel/OctoPrint/issues/1428). - * [#1479](https://github.com/foosel/OctoPrint/issues/1479): Make sure set cookies are post fixed with a port specific suffix and that the path they are set on takes the script root from the request into account. - * [#1483](https://github.com/foosel/OctoPrint/issues/1483): Filenames in file uploads may also now be encoded in ISO-8859-1, as defined in [RFC 7230](https://tools.ietf.org/html/rfc7230#section-3.2.4). Solves an issue when sending files with non-ASCII-characters in the file name from Slic3r. - * [#1491](https://github.com/foosel/OctoPrint/issues/1491): Fixed generate/delete API key in the user settings - * [#1492](https://github.com/foosel/OctoPrint/issues/1492): Fixed a bug in the software update plugin depending on the presence of the ``prerelease`` flag which is only present when added manually or using a non stable release channel. - -### More information - - * [Commits](https://github.com/foosel/OctoPrint/compare/1.2.15...1.2.16) - * Release Candidates: - * [1.2.16rc1](https://github.com/foosel/OctoPrint/releases/tag/1.2.16rc1) - * [1.2.16rc2](https://github.com/foosel/OctoPrint/releases/tag/1.2.16rc2) - -## 1.2.15 (2016-07-30) - -### Improvements - - * [#1425](https://github.com/foosel/OctoPrint/issues/1425) - Added a compatibility work around for plugins implementing the [`octoprint.comm.transport.serial_factory` hook](http://docs.octoprint.org/en/master/plugins/hooks.html#octoprint-comm-transport-serial-factory) but whose handler's `write` method did not return the number of written bytes (e.g. [GPX plugin including v2.5.2](http://plugins.octoprint.org/plugins/gpx/), [M33 Fio plugin including v1.2](http://plugins.octoprint.org/plugins/m33fio/)). - -### Bug Fixes - - * [#1423](https://github.com/foosel/OctoPrint/issues/1423) - Fixed an issue with certain printers dropping or garbling communication when setting the read timeout of the serial line. Removed the dynamic timeout setting introduced by [#1409](https://github.com/foosel/OctoPrint/issues/1409) to solve this. - * [#1425](https://github.com/foosel/OctoPrint/issues/1425) - Fixed an error when trying to close a printer connection that had not yet been opened and was `None` - * Fixed "Last Modified" header calculation for views where only one source file was present - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.14...1.2.15)) - -## 1.2.14 (2016-07-28) - -### Improvements - - * [#935](https://github.com/foosel/OctoPrint/issues/935) - Support alternative source file types and target extensions in [SlicerPlugins](http://docs.octoprint.org/en/master/plugins/mixins.html#slicerplugin). - * [#1393](https://github.com/foosel/OctoPrint/issues/1393) - Added dedicated sub commands on the job API to pause and resume a print job (instead of only offering a toggle option). - * Better "upload error" message with a list of supported extensions (instead of hardcoded ones) - * Use fuzzy times for print time estimation from GCODE analysis - * Allow M23 "File opened" response with no filename (RepRapPro) - * Allow intermediary startup page to forward query parameters and fragments from initial call to actual web frontend - * More error resilience when rendering templates (e.g. from plugins) - * Make sure that all bytes of a line to send to the printer have actually been sent - * "Tickle" printer when encountering a communication timeout while idle - * Report `CLOSED`/`CLOSED_WITH_ERROR` states as "Offline" in frontend for more consistency with startup `NONE` state which already was reported as "Offline" - * Another attempt at a saner print time estimation: Force linear (way less accurate) estimate if calculation of more accurate version takes too long, sanity check calculated estimate and use linear estimate if it looks wrong, improved threshold values for calculation. Read [the second half of this post on the mailing list](https://groups.google.com/forum/#!msg/octoprint/WWpm1FCUkAs/X3HomTM5DgAJ) on why accurate print time estimation is so difficult to achieve. - * Display print job progress percentage on progress bar. - * Added an indicator for print time left prediction accuracy and explanation of its origin as tooltip. - * Improved visual distinction of "State" sidebar panel info clusters. - -### Bug Fixes - - * [#1385](https://github.com/foosel/OctoPrint/issues/1385) - Send all non-protocol messages from printer to clients. - * [#1388](https://github.com/foosel/OctoPrint/issues/1388) - Track consecutive timeouts even when idle and disconnect from printer when it's not responding any longer. - * [#1391](https://github.com/foosel/OctoPrint/issues/1391) - Only use the first value from the X-Scheme header for the reverse proxy setup. Otherwise there could be problems when multiple reverse proxies were configured chained together, each adding their own header to the mix. - * [#1407](https://github.com/foosel/OctoPrint/issues/1407) - If a file is uploaded with the "print" flag set to true, make sure to clear that flag after the print job has been triggered so that now all following uploaded or selected files will start printing on their own. - * [#1409](https://github.com/foosel/OctoPrint/issues/1409) - Don't report a communication timeout after a heatup triggered by a print from SD. - * Fixed scrolling to freshly uploaded files, also now highlighting the file entry for better visibility. - * Fixed overeager preemptive caching of invalid protocols. - * Fix modal background of update confirmation not vanishing - * Ensure log entries and messages from printer are sent to frontend already converted to utf-8. Otherwise even one line in the log that can't be converted automatically without error can cause updates from the backend to not arrive. - * Report correct printer state including error strings even after disconnecting - * While printing, be sure to read the next line from file and send that if the current line was filtered - * Small fixes in the GCODE analysis - * Small fixes in the documentation - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.13...1.2.14)) - -## 1.2.13 (2016-06-16) - -### Bug Fixes - - * [#1373](https://github.com/foosel/OctoPrint/issues/1373): Don't parse `B:` as bed temperature when it shows up as part of a position report from `M114`. - * [#1374](https://github.com/foosel/OctoPrint/issues/1374): Don't try to perform a passive login when the components we'd need to inform about a change in login state aren't yet available. Solves a bug that lead - among other things - to the Plugin Manager and the Software Update Plugin not showing anything but misleading errors until the user logged out and back in. - * Fixed the temperature graph staying uninitialized until a connection to a printer was established. - * Fixed an error causing issues during frontend startup if the browser doesn't support tracking browser visibility. - * Fixed an error causing issues during frontend startup if the browser doesn't support the capabilities needed for the GCODE viewer. - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.12...1.2.13)) - -## 1.2.12 (2016-06-09) - -### Improvements - - * [#1338](https://github.com/foosel/OctoPrint/issues/1338): Threshold configuration fields now include information about how to specify the thresholds. - * Mark unrendered timelapses currently being processed (recording or rendering) in the list and remove action buttons so no accidental double-processing can take place. - * Removed file extension from "rendering" and "rendered" notifications, was misleading when using the [mp4 wrapper script](https://github.com/guysoft/OctoPi/issues/184). - * Added some new events for manipulation of slicing profiles. - * Small fix of the german translation. - -### Bug Fixes - - * [#1314](https://github.com/foosel/OctoPrint/issues/1314): Do not change the extension of `.g` files being uploaded to SD (e.g. `auto0.g`) - * [#1320](https://github.com/foosel/OctoPrint/issues/1320): Allow deletion of *.mp4 timelapse files (see [this wrapper script](https://github.com/guysoft/OctoPi/issues/184)). - * [#1324](https://github.com/foosel/OctoPrint/issues/1324): Make daemonized OctoPrint properly clean up its pid file again (see also [#1330](https://github.com/foosel/OctoPrint/pull/1330)). - * [#1326](https://github.com/foosel/OctoPrint/issues/1326): Do not try to clean up an unrendered timelapse while it is already being deleted (and produce way too much logging output in the process). - * [#1343](https://github.com/foosel/OctoPrint/issues/1343): Events are now processed in the order they are fired in, making e.g. the "timelapse rendering" message always appear before "timelapse failed" and hence not stay on forever in case of a failed timelapse. - * [#1344](https://github.com/foosel/OctoPrint/issues/1344): `ProgressPlugin`s now get also notified about a progress of 0%. - * [#1357](https://github.com/foosel/OctoPrint/issues/1357): Fixed wrongly named method call on editing access control options for a user, causing that to not work properly. - * [#1361](https://github.com/foosel/OctoPrint/issues/1361): Properly reload profile list for currently selected slicer in the slicing dialog on change of profiles. - * [#1364](https://github.com/foosel/OctoPrint/issues/1364): Fixed a race condition that could cause the UI to not initialize correctly due to 401 errors, leaving it in an unusable state until a reload. - * Fixed concurrent message pushing to the frontend being able to break push messages for the session by forcing synchronization of SockJS message sending. - * Do not require admin rights for connecting/disconnecting, like it was in 1.1.x (note that this is supposed to become configurable behaviour once [#1110](https://github.com/foosel/OctoPrint/issues/1110) gets implemented) - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.11...1.2.12)) - -## 1.2.11 (2016-05-04) - -### Important Announcement - -Due to a recent change in the financial situation of the project, the funding of OctoPrint is at stake. If you love OctoPrint and want to see its development continue at the pace of the past two years, please read on about its current funding situation and how you can help: ["I need your support"](http://octoprint.org/blog/2016/04/13/i-need-your-support/). - -### Improvements - - * Added option to treat resend requests as `ok` for such firmwares that do not send an `ok` after requesting a resend. If you printer communication gets stalled after a resend request from the firmware, try checking this option. - * Added an "About" dialog to properly inform about OctoPrint's license, contributors and supporters. - * Added a announcement plugin that utilizes the RSS feeds of the [OctoPrint Blog](http://octoprint.org/blog/) and the [plugin repository](http://plugins.octoprint.org) to display news to the user. By default only the "important announcement" category is enabled. This category will only be used for very rare situations such as making you aware of critical updates or important news. You can enable further categories (with more announcements to be expected) in the plugin's settings dialog. - -### Bug Fixes - - * [#1300](https://github.com/foosel/OctoPrint/issues/1300) - Removed possibility to accidentally disabling local file list by first limiting view to files from SD and then disabling SD support. - * [#1315](https://github.com/foosel/OctoPrint/issues/1315) - Fixed broken post roll on z-based timelapses. - * Fixed CSS data binding syntax on the download link in the files list - * Changed control distance from jQuery data into a knockout observerable and observerableArray - * Allow an unauthorized user to logout from a logedin interface state - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.10...1.2.11)) - -## 1.2.10 (2016-03-16) - -### Improvements - - * Improved performance of console output during plugin installation/deinstallation - * Slight performance improvements in the communication layer - * Log small log excerpt to `octoprint.log` upon encountering a communication error. - * Changed wording in "firmware error" notifications to better reflect that there was an error while communicating with the printer, since the error condition can also be triggered by serial errors while trying to establish a connection to the printer or when already connected. - * Support downloading ".mp4" timelapse files. You'll need a [custom wrapper script for timelapse rendering](https://github.com/guysoft/OctoPi/issues/184) for this to be relevant to you. See also [#1255](https://github.com/foosel/OctoPrint/pull/1255) - * The communication layer will now wait up to 10s after clicking disconnect in order to send any left-over lines from its buffers. - * Moved less commonly used configuration options in Serial settings into "Advanced options" roll-out. - -### Bug Fixes - - * [#1224](https://github.com/foosel/OctoPrint/issues/1224) - Fixed an issue introduced by the fix for [#1196](https://github.com/foosel/OctoPrint/issues/1196) that had the "Upload to SD" button stop working correctly. - * [#1226](https://github.com/foosel/OctoPrint/issues/1226) - Fixed an issue causing an error on disconnect after or cancelling of an SD print, caused by the unsuccessful attempt to record print recovery data for the file on the printer's SD card. - * [#1268](https://github.com/foosel/OctoPrint/issues/1268) - Only add bed temperature line to temperature management specific start gcode in CuraEngine invocation if a bed temperature is actually set in the slicing profile. - * [#1271](https://github.com/foosel/OctoPrint/issues/1271) - If a communication timeout occurs during an active resend request, OctoPrint will now not send an `M105` with an increased line number anymore but repeat the last resent command instead. - * [#1272](https://github.com/foosel/OctoPrint/issues/1272) - Don't add an extra `ok` for `M28` response. - * [#1273](https://github.com/foosel/OctoPrint/issues/1273) - Add an extra `ok` for `M29` response, but only if configured such in "Settings" > "Serial" > "Advanced options" > "Generate additional ok for M29" - * [#1274](https://github.com/foosel/OctoPrint/issues/1274) - Trigger `M20` only once after finishing uploading to SD - * [#1275](https://github.com/foosel/OctoPrint/issues/1275) - Prevent `M105` "cascade" due to communication timeouts - * Fixed wrong tracking of extruder heating up for `M109 Tn` commands in multi-extruder setups. - * Fixed start of SD file uploads not sending an `M110`. - * Fixed job data not being reset when disconnecting while printing. - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.9...1.2.10)) - -## 1.2.9 (2016-02-10) - -### Improvements - - * [#318](https://github.com/foosel/OctoPrint/issues/318) - Snapshots for timelapses are now named in a non-colliding, job-based way, allowing a new timelapse to start while the other is still being rendered (although printing with an active timelapse rendering job is not recommended and will be solved with a proper render job queue in a later version). Timelapses that were not successfully rendered are kept for 7 days (configurable, although not via the UI so far) and can be manually rendered or deleted through a new UI component within the timelapse tab that shows up if unrendered timelapses are detected. - * [#485](https://github.com/foosel/OctoPrint/issues/485) - "Timelapse rendering" notification is now persistent, even across reloads/client switches. That should make it easier to see that a rendering job is currently in progress. - * [#939](https://github.com/foosel/OctoPrint/issues/939) - Updated to Knockout 3.4.0 - * [#1204](https://github.com/foosel/OctoPrint/issues/1204) - Display total print time as estimated by GCODE viewer on GCODE viewer tab. That will allow access to an estimate even if the server hadn't yet calculated that when a print started. Note that due to slightly different implementation server and client side the resulting estimate might differ. - * OctoPrint now serves an intermediary page upon start that informs the user about the server still starting up. Once the server is detected as running, the page automatically switches to the standard interface. - * OctoPrint now displays a link to the release notes of an updated component in the update notification, the update confirmation and the version overview in the settings dialog. Please always make sure to at least skim over the release notes for new OctoPrint releases, they might contain important information that you need to know before updating. - * Improved initial page loading speeds by introducing a preemptive cache. OctoPrint will now record how you access it and on server start pre-render the page so it's ideally available in the server-side cache when you try to access it. - * Initialize login user name and password with an empty string and clear both on successful login (see [#1175](https://github.com/foosel/OctoPrint/pull/1175)). - * Added a "Refresh" button to the file list for people who modify the stored files externally (doing this is not encouraged however due to reasons of book keeping, e.g. metadata tracking etc). - * "Save" button on settings dialog is now disabled while background tasks (getting or receiving config data from the backend) are in progress. - * Improved performance of terminal tab on lower powered clients. Adaptive rate limiting now ensures the server backs off with log updates if the client can't process them fast enough. If the client is really slow, log updates get disabled automatically during printing. This behaviour can be disabled with override buttons in the terminal tab's advanced options if necessary. - * Added option to ignore any unhandled errors reported by the firmware and another option to only cancel ongoing prints on unhandled errors from the firmware (instead of instant disconnect that so far was the default). - * Made version compatibility check PEP440 compliant (important for plugin authors). - * Do not hiccup on manually sent `M28` commands. - * Persist print recovery data on print failures (origin and name of printed file, position in file when print was aborted, time and date of print failure). Currently this data isn't used anywhere, but it [can be accessed from plugins in order to add recovery functionality](https://github.com/foosel/OctoPrint-PrintRecoveryPoc) to OctoPrint. - * Small performance improvements in update checks. - * The file upload dialog will now only display files having an extension that's supported for upload (if the browser supports it, also see [#1196](https://github.com/foosel/OctoPrint/issues/1196)). - -### Bug Fixes - - * [#1007](https://github.com/foosel/OctoPrint/issues/1007) - Don't enable the "Print" button if no print job is selected. - * [#1181](https://github.com/foosel/OctoPrint/issues/1181) - Properly slugify UTF-8 only file names. - * [#1196](https://github.com/foosel/OctoPrint/issues/1196) - Do not show drag-n-drop overlay if server is offline. - * [#1208](https://github.com/foosel/OctoPrint/issues/1208) - Fixed `retraction_combing` profile setting being incorrectly used by bundled Cura plugin (see [#1209](https://github.com/foosel/OctoPrint/pull/1209)) - * Fixed OctoPrint compatibility check in the plugin manager, could report `False` for development versions against certain versions of Python's `setuptools` (thanks to @ignaworm who stumbled over this). - * Fixed a missing parameter in `PluginSettings.remove` call (see [#1177](https://github.com/foosel/OctoPrint/pull/1177)). - * Docs: Fixed the example for a custom `M114` control to also match negative coordinates. - * Reset scroll position in settings dialog properly when re-opening it or switching tabs. - * Fixed an issue that prevented system menu entries that were added to a so far empty system menu make the menu show up. - * Fixed an issue that made requests to restricted resources fail even though the first run wizard had been completed successfully. - * Fixed an issue where an unknown command or the suppression of a command could cause the communication to stall until a communication timeout was triggered. - * Strip [unwanted ANSI characters](https://github.com/pypa/pip/issues/3418) from output produced by pip versions 8.0.0, 8.0.1 and 8.0.3 that prevents our plugin installation detection from working correctly. - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.8...1.2.9)) - -## 1.2.8 (2015-12-07) - -### Notes for Upgraders - -#### A bug in 1.2.7 prevents directly updating to 1.2.8, here's what to do - -A bug in OctoPrint 1.2.7 (fixed in 1.2.8) prevents updating OctoPrint to version -1.2.8. If you try to perform the update, you will simply be told that "the update -was successful", but the update won't actually have taken place. To solve this -hen-egg-problem, a plugin has been made available that fixes said bug (through -monkey patching). - -The plugin is called "Updatefix 1.2.7" and can be found -[in the plugin repository](http://plugins.octoprint.org/plugins/updatefix127/) -and [on Github](https://github.com/OctoPrint/OctoPrint-Updatefix-1.2.7/). - -Before attempting to update your installation from version 1.2.7 to version 1.2.8, -please install the plugin via your plugin manager and restart your server. Note that -you will only see it in the Plugin Manager if you need it, since it's only compatible with -OctoPrint version 1.2.7. After you installed the plugin and restarted your server -you can update as usual. The plugin will self-uninstall once it detects that it's -running under OctoPrint 1.2.8. After the self-uninstall another restart of your server -will be triggered (if you have setup your server's restart command, defaults to -`sudo service octoprint restart` on OctoPi) in order to really get rid of any -left-overs, so don't be alarmed when that happens, it is intentional. - -**If you cannot or don't want to use the plugin**, alternatively you can switch -OctoPrint to "Commit" based tracking via the settings of the Software Update plugin, -update, then switch back to "Release" based tracking (see [this screenshot](https://i.imgur.com/wvkgiGJ.png)). - -#### Bed temperatures are now only displayed if printer profile has a heated bed configured - -This release fixes a [bug](https://github.com/foosel/OctoPrint/issues/1125) -that caused bed temperature display and controls to be available even if the -selected printer profile didn't have a heated bed configured. - -If your printer does have a heated bed but you are not seeing its temperature -in the "Temperature" tab after updating to 1.2.8, please make sure to check -the "Heated Bed" option in your printer profile (under Settings > Printer Profiles) -as shown [in this short GIF](http://i.imgur.com/wp1j9bs.gif). - -### Improvements - - * Version numbering now follows [PEP440](https://www.python.org/dev/peps/pep-0440/). - * Prepared some things for publishing OctoPrint on [PyPi](https://pypi.python.org/pypi) - in the future. - * [BlueprintPlugin mixin](http://docs.octoprint.org/en/master/plugins/mixins.html#blueprintplugin) - now has an `errorhandler` decorator that serves the same purpose as - [Flask's](http://flask.pocoo.org/docs/0.10/patterns/errorpages/#error-handlers) - ([#1059](https://github.com/foosel/OctoPrint/pull/1059)) - * Interpret `M25` in a GCODE file that is being streamed from OctoPrint as - indication to pause, like `M0` and `M1`. - * Cache rendered page and translation files indefinitely. That should - significantly improve performance on reloads of the web interface. - * Added the string "unknown command" to the list of ignored printer errors. - This should help with general firmware compatibility in case a firmware - lacks features. - * Added the strings "cannot open" and "cannot enter" to the list of ignored - printer errors. Those are errors that Marlin may report if there is an issue - with the printer's SD card. - * The "CuraEngine" plugin now makes it more obvious that it only targets - CuraEngine versions up to and including 15.04 and also links to the plugin's - homepage with more information right within the settings dialog. - * Browser tab visibility is now tracked by the web interface, disabling the - webcam and the GCODE viewer if the tab containing OctoPrint is not active. - That should reduce the amount of resource utilized by the web interface on - the client when it is not actively monitored. Might also help to mitigate - [#1065](https://github.com/foosel/OctoPrint/issues/1065), the final verdict - on that one is still out though. - * The printer log in the terminal tab will now be cut off after 3000 lines - even if autoscroll is disabled. If the limit is reached, no more log lines - will be added to the client's buffer. That ensures that the log will not - scroll and the current log excerpt will stay put while also not causing - the browser to run into memory errors due to trying to buffer an endless - amount of log lines. - * Increased timeout of "waiting for restart" after an update from 20 to 60sec - (20sec turned out to be too little for OctoPi for whatever reason). - * Added a couple of unit tests - -### Bug Fixes - - * [#1120](https://github.com/foosel/OctoPrint/issues/1120) - Made the watchdog - that monitors and handles the `watched` folder more resilient towards errors. - * [#1125](https://github.com/foosel/OctoPrint/issues/1125) - Fixed OctoPrint - displaying bed temperature and controls and allowing the sending of GCODE - commands targeting the bed (`M140`, `M190`) if the printer profile doesn't - have a heated bed configured. - * Fixed an issue that stopped the software updater working for OctoPrint. The - updater reports success updating, but no update has actually taken place. A - fix can be applied for this issue to OctoPrint version 1.2.7 via - [the Updatefix 1.2.7 plugin](https://github.com/OctoPrint/OctoPrint-Updatefix-1.2.7). - For more information please refer to the [Important information for people updating from version 1.2.7](#important-information-for-people-updating-from-version-127) - above. - * Fix: Current filename in job data should never be prefixed with `/` - * Only persist plugin settings that differ from the defaults. This way the - `config.yaml` won't be filled with lots of redundant data. It's the - responsibility of the plugin authors to responsibly handle changes in default - settings of their plugins and add data migration where necessary. - * Fixed a documentation bug ([#1067](https://github.com/foosel/OctoPrint/pull/1067)) - * Fixed a conflict with bootstrap-responsive, e.g. when using the - [ScreenSquish Plugin](http://plugins.octoprint.org/plugins/screensquish/) - ([#1103](https://github.com/foosel/OctoPrint/pull/1067)) - * Fixed OctoPrint still sending SD card related commands to the printer even - if SD card support is disabled (e.g. `M21`). - * Hidden files are no longer visible to the template engine, neither as (GCODE) - scripts nor as interface templates. - * The hostname and URL prefix via which the OctoPrint web interface is accessed - is now part of the cache key. Without that being the case the cache could - be created referring to something like `/octoprint/prefix/api/` for its API - endpoint (if accessed via `http://somehost:someport/octoprint/prefix/` first - time), which would then cause the interface to not work if accessed later - via another route (e.g. `http://someotherhost/`). - * Fixed a JavaScript error on finishing streaming of a file to SD. - * Fixed version reporting on detached HEADs (when the branch detection - reported "HEAD" instead of "(detached" - * Fixed some path checks for systems with symlinked paths - ([#1051](https://github.com/foosel/OctoPrint/pull/1051)) - * Fixed a bug causing the "Server Offline" overlay to pop _under_ the - "Please reload" overlay, which could lead to "Connection refused" browser - messages when clicking "Reload now" in the wrong moment. - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.7...1.2.8)) - -## 1.2.7 (2015-10-20) - -### Improvements - - * [#1062](https://github.com/foosel/OctoPrint/issues/1062) - Plugin Manager - now has a configuration dialog that among other things allows defining the - used `pip` command if auto detection proves to be insufficient here. - * Allow defining additional `pip` parameters in Plugin Manager. That might - make `sudo`-less installation of plugins possible in situations where it's - tricky otherwise. - * Improved timelapse processing (backported from `devel` branch): - * Individually captured frames cannot "overtake" each other anymore through - usage of a capture queue. - * Notifications will now be shown when the capturing of the timelapse's - post roll happens, including an approximation of how long that will take. - * Usage of `requests` instead of `urllib` for fetching the snapshot, - appears to also have [positive effects on webcam compatibility](https://github.com/foosel/OctoPrint/issues/1078). - * Some more defensive escaping for various settings in the UI (e.g. webcam URL) - * Switch to more error resilient saving of configuration files and other files - modified during runtime (save to temporary file & move). Should reduce risk - of file corruption. - * Downloading GCODE and STL files should now set more fitting `Content-Type` - headers (`text/plain` and `application/sla`) for better client side support - for "Open with" like usage scenarios. - * Selecting z-triggered timelapse mode will now inform about that not working - when printing from SD card. - * Software Update Plugin: Removed "The web interface will now be reloaded" - notification after successful update since that became obsolete with - introduction of the "Reload Now" overlay. - * Updated required version of `psutil` and `netifaces` dependencies. - -### Bug Fixes - - * [#1057](https://github.com/foosel/OctoPrint/issues/1057) - Better error - resilience of the Software Update plugin against broken/incomplete update - configurations. - * [#1075](https://github.com/foosel/OctoPrint/issues/1075) - Fixed support - of `sudo` for installing plugins, but added big visible warning about it - as it's **not** recommended. - * [#1077](https://github.com/foosel/OctoPrint/issues/1077) - Do not hiccup - on [UTF-8 BOMs](https://en.wikipedia.org/wiki/Byte_order_mark) (or other - BOMs for that matter) at the beginning of GCODE files. - * Fixed an issue that caused user sessions to not be properly associated, - leading to Sessions getting duplicated, wrongly saved etc. - * Fixed internal server error (HTTP 500) response on REST API calls with - unset `Content-Type` header. - * Fixed an issue leading to drag-and-drop file uploads to trigger frontend - processing in various other file upload widgets. - * Fixed a documentation error. - * Fixed caching behaviour on GCODE/STL downloads, was setting the `ETag` - header improperly. - * Fixed GCODE viewer not properly detecting change of currently visualized - file on Windows systems. - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.6...1.2.7)) - -## 1.2.6 (2015-09-02) - -### Improvements - - * Added support for version reporting on detached checkouts - (see [#1041](https://github.com/foosel/OctoPrint/pull/1041)) - -### Bug Fixes - - * Pinned requirement for [psutil](https://pypi.python.org/pypi/psutil) - dependency to version 3.1.1 of that library due to an issue when - installing version 3.2.0 of that library released on 2015-09-02 through - a `python setup.py install` on OctoPrint. Also pinned all other requirements - to definitive versions that definitely work while at it to keep that from - happening again. - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.5...1.2.6)) - -## 1.2.5 (2015-08-31) - -### Improvements - - * [#986](https://github.com/foosel/OctoPrint/issues/986) - Added tooltip for - "additional data" button in file list. - * [#1028](https://github.com/foosel/OctoPrint/issues/1028) - Hint about why - timelapse configuration is disabled. - * New central configuration option for commands to restart OctoPrint and to - restart and shut down the system OctoPrint is running on. This allows plugins - (like the Software Update Plugin or the Plugin Manager) and core functionality - to perform these common administrative tasks without the user needing to define - everything redundantly. - * Settings dialog now visualizes when settings are saving and when they being - retrieved. Also the Send/Cancel buttons are disabled while settings are saving - to prevent duplicated requests and concurrent retrieval of the settings by - multiple viewmodels is disabled as well. - * Better protection against rendering errors from templates provided by third - party plugins. - * Better protection against corrupting the configuration by using a temporary - file as intermediate buffer. - * Added warning to UI regarding Z timelapses and spiralized objects. - * Better compatibility with Repetier firmware: - * Added "Format Error" to whitelisted recoverable communication errors - (see also [#1032](https://github.com/foosel/OctoPrint/pull/1032)). - * Added option to ignore repeated resend requests for the same line (see - also discussion in [#1015](https://github.com/foosel/OctoPrint/pull/1015)). - * Software Update Plugin: - * Adjusted to utilize new centralized restart commands (see above). - * Allow configuration of checkout folder and version tracking type via - Plugin Configuration. - * Display message to user if OctoPrint's checkout folder is not configured - or a non-release version is running and version tracking against releases - is enabled. - * Clear version cache when a change in the check configuration is detected. - * Mark check configurations for which an update is not possible. - * Made disk space running low a bit more obvious through visual warning on - configurable thresholds. - -### Bug Fixes - - * [#985](https://github.com/foosel/OctoPrint/issues/985) - Do not hiccup on - unset `Content-Type` part headers for multipart file uploads. - * [#1001](https://github.com/foosel/OctoPrint/issues/1001) - Fixed connection - tab not unfolding properly (see also [#1002](https://github.com/foosel/OctoPrint/pull/1002)). - * [#1012](https://github.com/foosel/OctoPrint/issues/1012) - All API - responses now set no-cache headers, making the Edge browser behave a bit better - * [#1019](https://github.com/foosel/OctoPrint/issues/1019) - Better error - handling of problems when trying to write the webassets cache. - * [#1021](https://github.com/foosel/OctoPrint/issues/1021) - Properly handle - serial close on Macs. - * [#1031](https://github.com/foosel/OctoPrint/issues/1031) - Special - handling of `M112` (emergency stop) command: - * Jump send queue - * In case the printer's firmware doesn't understand it yet, at least - shutdown all of the heaters - * Disconnect - * Properly reset job progress to 0% when restarting a previously completed - printjob (see [#998](https://github.com/foosel/OctoPrint/pull/998)). - * Report an update as failed if the `pip` command returns a return code that - indicates failure. - * Fixed sorting of templates: could only be sorted by name, individual - configurations were ignored (see [#1022](https://github.com/foosel/OctoPrint/pull/1022)). - * Fixed positioning of custom context menus: were offset due to changes in - overall positioning settings (see [#1023](https://github.com/foosel/OctoPrint/pull/1023)). - * Software Update: Don't use display version for comparison of git commit - hashs. - * Fixed temperature parsing for multi extruder setups. - * Fixed nested vertical and horizontal custom control layouts. - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.4...1.2.5)) - -## 1.2.4 (2015-07-23) - -### Improvements - - * `RepeatedTimer` now defaults to `daemon` set to `True`. This makes sure - plugins using it don't have to remember to set that flag themselves in - order for the server to properly shut down when timers are still active. - * Fixed a typo in the docs about `logging.yaml` (top level element is - `loggers`, not `logger`). - * Support for plugins with external dependencies (`dependency_links` in - setuptools), interesting for plugin authors who need to depend on Python - libraries that are (not yet) available on PyPI. - * Better resilience against errors within plugins. - -### Bug Fixes - - * Do not cache web page when running for the first time, to avoid caching - the first run dialog popup along side with it. This should solve issues - people were having when configuring OctoPrint for the first time, then - reloading the page without clearing the cache, being again prompted with - the dialog with no chance to clear it. - * Fix/workaround for occasional white panes in settings dialog on Safari 8, - which appears to have an issue with fixed positioning. - * Fixed form field truncation in upload requests that could lead to problems - when trying to import Cura profiles with names longer than 28 characters. - * Fixed webcam rotation for timelapse rendering. - * Fixed user settings not reaching the editor in the frontend. - * Notifications that are in process of being closed don't open again on - mouse over (that was actually more of an unwanted feature). - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.3...1.2.4)) - -## 1.2.3 (2015-07-09) - -### Improvements - - * New option to actively poll the watched folder. This should make it work also - if it is mounted on a filesystem that doesn't allow getting notifications - about added files through notification by the operating system (e.g. - network shares). - * Better resilience against senseless temperature/SD-status-polling intervals - (such as 0). - * Log exceptions during writing to the serial port to `octoprint.log`. - -### Bug Fixes - - * [#961](https://github.com/foosel/OctoPrint/pull/961) - Fixed a JavaScript error that caused an error to be logged when "enter" was pressed in file or plugin search. - * [#962](https://github.com/foosel/OctoPrint/pull/962) - ``url(...)``s in packed CSS and LESS files should now be rewritten properly too to refer to correct paths - * Update notifications were not vanishing properly after updating: - * Only use version cache for update notifications if the OctoPrint version still is the same to make sure the cache gets invalidated after an external update of OctoPrint. - * Do not persist version information when saving settings of the Software Update plugin - * Always delete files from the ``watched`` folder after importing then. Using file preprocessor plugins could lead to the files staying there. - * Fixed an encoding problem causing OctoPrint's Plugin Manager and Software Update plugins to choke on UTF-8 characters in the update output. - * Fixed sorting by file size in file list - * More resilience against missing plugin assets: - * Asset existence will now be checked before they get included - in the assets to bundle by webassets, logging a warning if a - file isn't present. - * Monkey-patched webassets filter chain to not die when a file - doesn't exist, but to log an error instead and just return - an empty file instead. - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.2...1.2.3)) - -## 1.2.2 (2015-06-30) - -### Bug Fixes - -* Fixed an admin-only security issue introduced in 1.2.0, updating is strongly advised. - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.1...1.2.2)) - -## 1.2.1 (2015-06-30) - -### Improvements - -* More flexibility when interpreting compatibility data from plugin repository. If compatibility information is provided - only as a version number it's prefixed with `>=` for the check (so stating a compatibility of only - `1.2.0` will now make the plugin compatible to OctoPrint 1.2.0+, not only 1.2.0). Alternatively the compatibility - information may now contain stuff like `>=1.2,<1.3` in which case the plugin will only be shown as compatible - to OctoPrint versions 1.2.0 and up but not 1.3.0 or anything above that. See also - [the requirement specification format of the `pkg_resources` package](https://pythonhosted.org/setuptools/pkg_resources.html#requirements-parsing). -* Only print the commands of configured event handlers to the log when a new `debug` flag is present in the config - (see [the docs](http://docs.octoprint.org/en/master/configuration/config_yaml.html#events)). Reduces risk of disclosing sensitive data when sharing log files. - -### Bug Fixes - -* [#956](https://github.com/foosel/OctoPrint/issues/956) - Fixed server crash when trying to configure a default - slicing profile for a still unconfigured slicer. -* [#957](https://github.com/foosel/OctoPrint/issues/957) - Increased maximum allowed body size for plugin archive uploads. -* Bugs without tickets: - * Clean exit on `SIGTERM`, calling the shutdown functions provided by plugins. - * Don't disconnect on `volume.init` errors from the firmware. - * `touch` uploaded files on local file storage to ensure proper "uploaded date" even for files that are just moved - from other locations of the file system (e.g. when being added from the `watched` folder). - -([Commits](https://github.com/foosel/OctoPrint/compare/1.2.0...1.2.1)) - -## 1.2.0 (2015-06-25) - -### Note for Upgraders - - * The [Cura integration](https://github.com/daid/Cura) has changed in such a way that OctoPrint now calls the - [CuraEngine](https://github.com/Ultimaker/CuraEngine) directly instead of depending on the full Cura installation. See - [the wiki](https://github.com/foosel/OctoPrint/wiki/Plugin:-Cura) for instructions on how to change your setup to - accommodate the new integration. - -### New Features - -* OctoPrint now has a [plugin system](http://docs.octoprint.org/en/master/plugins/index.html) which allows extending its - core functionality. - * Plugins may be installed through the new and bundled [Plugin Manager Plugin](https://github.com/foosel/OctoPrint/wiki/Plugin:-Plugin-Manager) - available in OctoPrint's settings. This Plugin Manager also allows browsing and easy installation of plugins - registered on the official [OctoPrint Plugin Repository](http://plugins.octoprint.org). - * For interested developers there is a [tutorial available in the documentation](http://docs.octoprint.org/en/master/plugins/gettingstarted.html) - and also a [cookiecutter template](https://github.com/OctoPrint/cookiecutter-octoprint-plugin) to quickly bootstrap - new plugins. -* Added internationalization of UI. Translations of OctoPrint are being crowd sourced via [Transifex](https://www.transifex.com/projects/p/octoprint/). - Language Packs for both the core application as well as installed plugins can be uploaded through a new management - dialog in Settings > Appearance > Language Packs. A translation into German is included, further language packs - will soon be made available. -* Printer Profiles: Printer properties like print volume, extruder offsets etc are now managed via Printer Profiles. A - connection to a printer will always have a printer profile associated. -* File management now supports STL files as first class citizens (including UI adjustments to allow management of - uploaded STL files including removal and reslicing) and also allows folders (not yet supported by UI). STL files - can be downloaded like GCODE files. -* Slicing has been greatly improved: - * It now allows for a definition of slicing profiles to use for slicing plus overrides which can be defined per slicing - job (defining overrides is not yet part of the UI but it's on the roadmap). - * A new slicing dialog has been added which allows (re-)slicing uploaded STL files (which are now displayed in the file list - as well). The slicing profile and printer profile to use can be specified here as well as the file name to which to - slice to and the action to take after slicing has been completed (none, selecting the sliced GCODE for printing or - starting to print it directly) - * The slicing API allows positioning the model to slice on the print bed (Note: this is not yet available in the UI). - * Slicers themselves are integrated into the system via ``SlicingPlugins``. - * Bundled [Cura Plugin](https://github.com/foosel/OctoPrint/wiki/Plugin:-Cura) allows slicing through CuraEngine up - to and including 15.04. Existing Cura slicing profiles can be imported through the web interface. -* New file list: Pagination is gone, no more (mobile incompatible) pop overs, instead scrollable and with instant - search -* You can now define a folder (default: `~/.octoprint/watched`) to be watched for newly added GCODE (or -- if slicing - support is enabled -- STL) files to automatically add. -* New type of API key: [App Session Keys](http://docs.octoprint.org/en/master/api/apps.html) for trusted applications -* OctoPrint now supports `action:...` commands received via debug messages (`// action:...`) from the printer. Currently supported are - - `action:pause`: Pauses the current job in OctoPrint - - `action:resume`: Resumes the current job in OctoPrint - - `action:disconnect`: Disconnects OctoPrint from the printer - Plugins can add supported commands by [hooking](http://docs.octoprint.org/en/master/plugins/hooks.html) into the - ``octoprint.comm.protocol.action`` hook -* Mousing over the webcam image in the control tab enables key control mode, allowing you to quickly move the axis of your - printer with your computer's keyboard ([#610](https://github.com/foosel/OctoPrint/pull/610)): - - arrow keys: X and Y axes - - W, S / PageUp, PageDown: Z axes - - Home: Home X and Y axes - - End: Home Z axes - - 1, 2, 3, 4: change step size used (0.1, 1, 10, 100mm) -* Controls for adjusting feed and flow rate factor added to Controls ([#362](https://github.com/foosel/OctoPrint/issues/362)) -* Custom controls now also support slider controls -* Custom controls now support a row layout -* Users can now define custom GCODE scripts to run upon starting/pausing/resuming/success/failure of a print or for - custom controls ([#457](https://github.com/foosel/OctoPrint/issues/457), [#347](https://github.com/foosel/OctoPrint/issues/347)) -* Bundled [Discovery Plugin](https://github.com/foosel/OctoPrint/wiki/Plugin:-Discovery) allows discovery of OctoPrint - instances via SSDP/UPNP and optionally also via ZeroConf/Bonjour/Avahi. -* Bundled [Software Update Plugin](https://github.com/foosel/OctoPrint/wiki/Plugin:-Software-Update) takes care of notifying - about new OctoPrint releases and also allows updating if configured as such. Plugins may register themselves with the - update notification and application process through a new hook ["octoprint.plugin.softwareupdate.check_config"](https://github.com/foosel/OctoPrint/wiki/Plugin:-Software-Update#octoprintpluginsoftwareupdatecheck_config). - -### Improvements - -* Logging is now configurable via config file -* Added last print time to additional GCODE file information -* Better error handling for capture issues during timelapse creation & more robust handling of missing images during - timelapse creation -* Start counting the layers at 1 instead of 0 in the GCODE viewer -* Upgraded [Font Awesome](https://fortawesome.github.io/Font-Awesome/) to version 3.2.1 -* Better error reporting for timelapse rendering and system commands -* Custom control can now be defined so that they show a Confirm dialog with configurable text before executing - ([#532](https://github.com/foosel/OctoPrint/issues/532) and [#590](https://github.com/foosel/OctoPrint/pull/590)) -* Serial communication: Also interpret lines starting with "!!" as errors -* Added deletion of pyc files to the `python setup.py clean` command -* Settings now show a QRCode for the API Key ([#637](https://github.com/foosel/OctoPrint/pull/637)) -* Username in UI is no longer enclosed in scare quotes ([#595](https://github.com/foosel/OctoPrint/pull/595)) -* Username in login dialog is not automatically capitalized on mobile devices anymore ([#639](https://github.com/foosel/OctoPrint/pull/639)) -* "Slicing Done" and "Streaming Done" notifications now have a green background ([#558](https://github.com/foosel/OctoPrint/issues/558)) -* Files that are currently in use, be it for slicing, printing or whatever, are now tracked and can not be deleted as - long as they are in use -* Settings in UI get refreshed when opening settings dialog -* New event "SettingsUpdated" -* "Print time left" is now not displayed until it becomes somewhat stable. Display in web interface now also happens - in a fuzzy way instead of the format hh:mm:ss, to not suggest a high accuracy anymore where the can't be one. Additionally - OctoPrint will use data from prior prints to enhance the initial print time estimation. -* Added handler for uncaught exceptions to make sure those get logged, should make the logs even more useful for analysing - bug reports -* The server now tracks the modification date of the configuration file and reloads it prior to saving the config if - it has been changed during runtime by external editing, hence no config settings added manually while the server - was running should be overwritten anymore. -* Display a "Please Reload" overlay when a new server version or a change in installed plugins is detected after a - reconnect to the server. -* Better handling of errors on the websocket - no more logging of the full stack trace to the log, only a warning - message for now. -* Daemonized OctoPrint now cleans up its pidfile when receiving a TERM signal ([#711](https://github.com/foosel/OctoPrint/issues/711)) -* Added serial types for OpenBSD ([#551](https://github.com/foosel/OctoPrint/pull/551)) -* Improved behaviour of terminal: - * Disabling autoscrolling now also stops cutting off the log while it's enabled, effectively preventing log lines from - being modified at all ([#735](https://github.com/foosel/OctoPrint/issues/735)) - * Applying filters displays ``[...]`` where lines where removed and doesn't cause scrolling on filtered lines - anymore ([#286](https://github.com/foosel/OctoPrint/issues/286)) - * Added a link to scroll to the end of the terminal log (useful for when autoscroll is disabled) - * Added a link to select all current contents of the terminal log for easy copy-pasting - * Added a display of how many lines are displayed, how many are filtered and how many are available in total -* Frame rate for timelapses can now be configured per timelapse ([#782](https://github.com/foosel/OctoPrint/pull/782)) -* Added an option to specify the amount of encoding threads for FFMPEG ([#785](https://github.com/foosel/OctoPrint/pull/785)) -* "Disconnected" screen now is not shown directly after a close of the socket, instead the client first tries to - directly reconnect once, and only if that doesn't work displays the dialog. Should reduce short popups of the dialog - due to shaky network connections and/or weird browser behaviour when downloading things from the UI. -* Development dependencies can now be installed with ``pip -e .[develop]`` -* White and transparent colors ;) are supported for the navigation bar ([#789](https://github.com/foosel/OctoPrint/pull/789)) -* Drag-n-drop overlay for file uploads now uses the full available screen space, improving usability on high resolution - displays ([#187](https://github.com/foosel/OctoPrint/issues/187)) -* OctoPrint server should no longer hang when big changes in the system time happen, e.g. after first contact to an - NTP server on a Raspberry Pi image. Achieved through monkey patching Tornado with - [this PR](https://github.com/tornadoweb/tornado/pull/1290). -* Serial ports matching ``/dev/ttyAMA*`` are not anymore listed by default (this was the reason for a lot of people - running into problems while attempting to connect to their printer on their Raspberry Pis, on which ``/dev/ttyAMA0`` - is the OS's serial console by default). Added configuration of additional ports to the Serial Connection section in - the Settings to make it easier for those people who do indeed have their printer connected to ``/dev/ttyAMA0``. -* Better behaviour of the settings dialog on low-width devices, navigation and content also now scroll independently - from each other (see also [#823](https://github.com/foosel/OctoPrint/pull/823)) -* Renamed "Temperature Timeout" and "SD Status Timeout" in Settings to "Temperature Interval" and "SD Status Interval" - to better reflect what those values are actually used for. -* Added support for rectangular printer beds with the origin in the center ([#682](https://github.com/foosel/OctoPrint/issues/682) - and [#852](https://github.com/foosel/OctoPrint/pull/852)). Printer profiles now contain a new settings ``volume.origin`` - which can either be ``lowerleft`` or ``center``. For circular beds only ``center`` is supported. -* Made baudrate detection a bit more solid, still can't perform wonders. -* Only show configuration options for additional extruders if more than one is available, and don't include offset - configuration for first nozzle which acts as reference for the other offsets ([#677](https://github.com/foosel/OctoPrint/issues/677)). -* Cut off of the temperature graph is now not based on the number of data points any more but on the actual time of the - data points. Anything older than ``n`` minutes will be cut off, with ``n`` defaulting to 30min. This value can be - changed under "Temperatures" in the Settings ([#343](https://github.com/foosel/OctoPrint/issues/343)). -* High-DPI support for the GCode viewer ([#837](https://github.com/foosel/OctoPrint/issues/837)). -* Stop websocket connections from multiplying ([#888](https://github.com/foosel/OctoPrint/pull/888)). -* New setting to rotate webcam by 90° counter clockwise ([#895](https://github.com/foosel/OctoPrint/issues/895) and - [#906](https://github.com/foosel/OctoPrint/pull/906)) -* System commands now be set to a) run asynchronized by setting their `async` property to `true` and b) to ignore their - result by setting their `ignore` property to `true`. -* Various improvements of newly introduced features over the course of development: - * File management: The new implementation will migrate metadata from the old one upon first startup after upgrade from - version 1.1.x to 1.2.x. That should speed up initial startup. - * File management: GCODE Analysis backlog processing has been throttled to not take up too many resources on system - startup. Freshly uploaded files should still be analyzed at full speed. - * Plugins: SettingsPlugins may track versions of configuration format stored in `config.yaml`, including a custom - migration method getting called when a mismatch between the currently stored configuration format version and the one - reported by the plugin as current is detected. - * Plugins: Plugins may now have a folder for plugin related data whose path can be retrieved from the plugin itself - via its new method [`get_plugin_data_folder`](http://docs.octoprint.org/en/master/modules/plugin.html#octoprint.plugin.types.OctoPrintPlugin.get_plugin_data_folder). - * Plugin Manager: Don't allow plugin management actions (like installing/uninstalling or enabling/disabling) while the - printer is printing (see also unreproduced issue [#936](https://github.com/foosel/OctoPrint/issues/936)). - * Plugin Manager: More options to try to match up installed plugin packages with discovered plugins. - * Plugin Manager: Display a more friendly message if after the installation of a plugin it could not be correctly - identifier. - * Software Update: Enforce refreshing of available updates after any changes in enabled plugins. - -### Bug Fixes - -* [#435](https://github.com/foosel/OctoPrint/issues/435) - Always interpret negative duration (e.g. for print time left) - as 0 -* [#516](https://github.com/foosel/OctoPrint/issues/516) - Also require API key even if ACL is disabled. -* [#556](https://github.com/foosel/OctoPrint/issues/556) - Allow login of the same user from multiple browsers without - side effects -* [#612](https://github.com/foosel/OctoPrint/issues/612) - Fixed GCODE viewer in zoomed out browsers -* [#633](https://github.com/foosel/OctoPrint/issues/633) - Correctly interpret temperature lines from multi extruder - setups under Smoothieware -* [#680](https://github.com/foosel/OctoPrint/issues/680) - Don't accidentally include a newline from the MIME headers - in the parsed multipart data from file uploads -* [#709](https://github.com/foosel/OctoPrint/issues/709) - Properly initialize time estimation for SD card transfers too -* [#715](https://github.com/foosel/OctoPrint/issues/715) - Fixed an error where Event Triggers of type command caused - and exception to be raised due to a misnamed attribute in the code -* [#717](https://github.com/foosel/OctoPrint/issues/717) - Use ``shutil.move`` instead of ``os.rename`` to avoid cross - device renaming issues -* [#752](https://github.com/foosel/OctoPrint/pull/752) - Fix error in event handlers sending multiple gcode commands. -* [#780](https://github.com/foosel/OctoPrint/issues/780) - Always (re)set file position in SD files to 0 so that reprints - work correctly -* [#784](https://github.com/foosel/OctoPrint/pull/784) - Also include ``requirements.txt`` in files packed up for - ``python setup.py sdist`` -* [#330](https://github.com/foosel/OctoPrint/issues/330) - Ping pong sending to fix potential acknowledgement errors. - Also affects [#166](https://github.com/foosel/OctoPrint/issues/166), [#470](https://github.com/foosel/OctoPrint/issues/470) - and [#490](https://github.com/foosel/OctoPrint/issues/490). A big thank you to all people involved in these tickets - in getting to the ground of this. -* [#825](https://github.com/foosel/OctoPrint/issues/825) - Fixed "please visualize" button of large GCODE files -* Various fixes of bugs in newly introduced features and improvements: - * [#625](https://github.com/foosel/OctoPrint/pull/625) - Newly added GCODE files were not being added to the analysis - queue - * [#664](https://github.com/foosel/OctoPrint/issues/664) - Fixed jog controls again - * [#677](https://github.com/foosel/OctoPrint/issues/677) - Fixed extruder offsets not being properly editable in - printer profiles - * [#678](https://github.com/foosel/OctoPrint/issues/678) - SockJS endpoints is now referenced by relative URL - using ``url_for``, should solve any issues with IE11. - * [#683](https://github.com/foosel/OctoPrint/issues/683) - Fixed heated bed option not being properly displayed in - printer profiles - * [#685](https://github.com/foosel/OctoPrint/issues/685) - Quoted file name for Timelapse creation to not make - command hiccup on ``~`` in file name - * [#709](https://github.com/foosel/OctoPrint/issues/709) - Fixed file sending to SD card - * [#714](https://github.com/foosel/OctoPrint/issues/714) - Fixed type validation of printer profiles - * Heating up the heated bed (if present) was not properly configured in CuraEngine plugin - * [#720](https://github.com/foosel/OctoPrint/issues/720) - Fixed translation files not being properly copied over - during install - * [#724](https://github.com/foosel/OctoPrint/issues/724) - Fixed timelapse deletion for timelapses with non-ascii - characters in their name - * [#726](https://github.com/foosel/OctoPrint/issues/726) - Fixed ``babel_refresh`` command - * [#759](https://github.com/foosel/OctoPrint/pull/759) - Properly initialize counter for template plugins of type - "generic" - * [#775](https://github.com/foosel/OctoPrint/pull/775) - Error messages in javascript console show the proper name - of the objects - * [#795](https://github.com/foosel/OctoPrint/issues/795) - Allow adding slicing profiles for unconfigured slicers - * [#809](https://github.com/foosel/OctoPrint/issues/809) - Added proper form validation to printer profile editor - * [#824](https://github.com/foosel/OctoPrint/issues/824) - Settings getting lost when switching between panes in - the settings dialog (fix provided by [#879](https://github.com/foosel/OctoPrint/pull/879)) - * [#892](https://github.com/foosel/OctoPrint/issues/892) - Preselected baudrate is now properly used for auto detected - serial ports - * [#909](https://github.com/foosel/OctoPrint/issues/909) - Fixed Z-Timelapse for Z changes on ``G1`` moves. - * Fixed another instance of a missing `branch` field in version dicts generated by versioneer (compare - [#634](https://github.com/foosel/OctoPrint/pull/634)). Caused an issue when installing from source archive - downloaded from Github. - * [#931](https://github.com/foosel/OctoPrint/issues/931) - Adjusted `octoprint_setuptools` to be compatible to older - versions of setuptools potentially site-wide installed on hosts. - * [#942](https://github.com/foosel/OctoPrint/issues/942) - Settings can now be saved again after installing a new - plugin. Plugins must not use `super` anymore to call parent implementation of `SettingsPlugin.on_settings_save` but - should instead switch to `SettingsPlugin.on_settings_save(self, ...)`. Settings API will capture related - `TypeErrors` and log a big warning to the log file indicating which plugin caused the problem and needs to be - updated. Also updated all bundled plugins accordingly. - * Software Update: Don't persist more check data than necessary in the configuration. Solves an issue where persisted - information overrode updated check configuration reported by plugins, leading to a "an update is available" loop. - An auto-migration function was added that should remove the redundant data. -* Various fixes without tickets: - * GCODE viewer now doesn't stumble over completely extrusionless GCODE files - * Do not deliver the API key on settings API unless user has admin rights - * Don't hiccup on slic3r filament_diameter comments in GCODE generated for multi extruder setups - * Color code successful or failed print results directly in file list, not just after a reload - * Changing Timelapse post roll activates save button - * Timelapse post roll is loaded properly from config - * Handling of files on the printer's SD card contained in folders now works correctly - * Don't display a "Disconnected" screen when trying to download a timelapse in Firefox - * Fixed handling of SD card files in folders - * Fixed refreshing of timelapse file list upon finished rendering of a new one - * Fixed ``/api/printer`` which wasn't adapter yet to new internal offset data model - * Made initial connection to printer a bit more responsive: Having to wait for the first serial timeout before sending - the first ``M105`` even when not waiting for seeing a "start" caused unnecessary wait times for reaching the - "Operational" state. - * Log cancelled prints only once (thanks to @imrahil for the headsup) - -### More information - - * [Commits](https://github.com/foosel/OctoPrint/compare/1.1.2...1.2.0) - * Release Candidates: - * [RC1](https://github.com/foosel/OctoPrint/releases/tag/1.2.0-rc1) - * [RC2](https://github.com/foosel/OctoPrint/releases/tag/1.2.0-rc2) - * [RC3](https://github.com/foosel/OctoPrint/releases/tag/1.2.0-rc3) - -## 1.1.2 (2015-03-23) - -### Improvements - -* Added deletion of `*.pyc` files to `python setup.py clean` command, should help tremendously when switching branches (backported - from [9e014eb](https://github.com/foosel/OctoPrint/commit/9e014eba1feffde11ed0601d9c911b8cac9f3fb0)) -* Increased default communication and connection timeouts -* [#706](https://github.com/foosel/OctoPrint/issues/706) - Do not truncate error reported from printer - -### Bug Fixes - -* [#539](https://github.com/foosel/OctoPrint/issues/539) - Limit maximum number of tools, sanity check tool numbers in - GCODE files against upper limit and refuse to create 10000 tools due to weird slicers. (backported from `devel`) -* [#634](https://github.com/foosel/OctoPrint/pull/634) - Fixed missing `branch` fields in version dicts generated - by versioneer -* [#679](https://github.com/foosel/OctoPrint/issues/679) - Fix error where API state is requested and printer is offline - (backport of [619fe9a](https://github.com/foosel/OctoPrint/commit/619fe9a0e78826bd1524b235a910156439bcb6d7)). -* [#719](https://github.com/foosel/OctoPrint/issues/719) - Properly center print bed in GCODE viewer -* [#780](https://github.com/foosel/OctoPrint/issues/780) - Always (re)set file position in SD files to 0 so that reprints - work correctly (backported from ``devel``) -* [#801](https://github.com/foosel/OctoPrint/issues/801) - Fixed setting of bed temperature offset -* [IRC] - Don't hiccup on slic3r ``filament_diameter`` comments generated for multi extruder setups -* [ML] - Fixed relative URL to SockJS endpoint, wasn't yet using the proper base url -* [unreported] & [#698](https://github.com/foosel/OctoPrint/issues/698) - Generated URLs now take X-Forwarded-Host header - sent by proxies into account for included host and port, also fixed [#698](https://github.com/foosel/OctoPrint/issues/698) - introduced by this -* [unreported] Fixed a bug causing gcodeInterpreter to hiccup on GCODES containing invalid coordinates such as Xnan or - Yinf (backported from `devel`) -* Small fixes for timelapse creation: - - [#344](https://github.com/foosel/OctoPrint/issues/344) - Made timelapses capable of coping with missing captures in between by decrementing the image counter again if there - was an error fetching the latest image from the snapshot URL (backport of [1a7a468](https://github.com/foosel/OctoPrint/commit/1a7a468eb65fdf2a13b4c7a7723280e822c9c34b) - and [bf9d5ef](https://github.com/foosel/OctoPrint/commit/bf9d5efe43a1e57aacd8512125082ddca06b4efc)) - - [#693](https://github.com/foosel/OctoPrint/issues/693) - Try not to capture an image if image counter is still unset - - [unreported] Synchronize image counter decrementing as well as incrementing to prevent rare race conditions when generating the - image file names - -([Commits](https://github.com/foosel/OctoPrint/compare/1.1.1...1.1.2)) - -## 1.1.1 (2014-10-27) - -### Improvements - -* The API is now enabled by default and the API key -- if not yet set -- will be automatically generated on first - server start and written back into ``config.yaml`` -* Event subscriptions are now enabled by default (it was an accident that they weren't) -* Generate the key used for session hashing individually for each server instance -* Generate the salt used for hashing user passwords individually for each server instance - -### Bug Fixes - -* [#580](https://github.com/foosel/OctoPrint/issues/580) - Properly unset job data when instructed so by callers -* [#604](https://github.com/foosel/OctoPrint/issues/604) - Properly initialize settings basedir on server startup -* [IRC] Also allow downloading .g files via Tornado - -([Commits](https://github.com/foosel/OctoPrint/compare/1.1.0...1.1.1)) - -## 1.1.0 (2014-09-03) - -### New Features - -* New REST API, including User API Keys additionally to the global API key. Please note that **this will break existing - API clients** as it replaces the old API (same endpoint). You can find the documentation of the new API at - [docs.octoprint.org](http://docs.octoprint.org/en/1.1.0/api/index.html). -* New Event structure allows more flexibility regarding payload data, configuration files will be migrated automatically. - You can find the documentation of the new event format and its usage at [docs.octoprint.org](http://docs.octoprint.org/en/1.1.0/events/index.html). -* Support for multi extruder setups. With this OctoPrint now in theory supports an unlimited amount of extruders, however - for now it's artificially limited to 9. -* Log files can be accessed from within the browser via the Settings dialog ([#361](https://github.com/foosel/OctoPrint/pull/361)) -* Timelapses can now have a post-roll duration configured which will be rendered into the video too to not let it - end so abruptly ([#384](https://github.com/foosel/OctoPrint/issues/384)) -* The terminal tab now has a command history ([#388](https://github.com/foosel/OctoPrint/pull/388)) - -### Improvements - -* Stopping the application via Ctrl-C produces a less scary message ([#277](https://github.com/foosel/OctoPrint/pull/277)) -* Webcam stream is disabled when control tab is not in focus, reduces bandwidth ([#316](https://github.com/foosel/OctoPrint/issues/316)) -* M and G commands entered in Terminal tab are automatically converted to uppercase -* GCODE viewer now only loads automatically if GCODE file size is beneath certain threshold (different ones for desktop - and mobile devices), only actually loads file if user acknowledges that this might be too much for his browser -* Added time needed for printing file to PrintDone event's payload ([#333](https://github.com/foosel/OctoPrint/issues/333)) -* Also provide the filename (basename without the path) in print events -* Support for circular beds in the GCODE viewer ([#407](https://github.com/foosel/OctoPrint/pull/407)) -* The dimensions of the print bed can now be configured via the Settings ([#396](https://github.com/foosel/OctoPrint/pull/396)) -* Target temperature reporting format of Repetier Firmware is now supported as well ([360](https://github.com/foosel/OctoPrint/issues/360)) -* Version tracking now based on git tagging and [versioneer](https://github.com/warner/python-versioneer/). Version number, - git commit and branch get reported in the format `--g ( branch)`, - e.g. `1.2.0-dev-172-ga48b5de (devel branch)`. -* Made "Center viewport on model" and "Zoom in on model" in the GCODE viewer automatically deselect and de-apply if - viewport gets manipulated by the user ([#398](https://github.com/foosel/OctoPrint/issues/398)) -* GCODE viewer now interprets inverted axes for printer control and mirrors print bed accordingly ([#431](https://github.com/foosel/OctoPrint/issues/431)) -* Added `clean` command to `setup.py`, removes old build artifacts (mostly interesting for developers) -* Added version resource on API which reports application and API version -* Made the navbar static instead of fixed to improve usability on mobile devices ([#257](https://github.com/foosel/OctoPrint/issues/257)) -* Switch to password field upon enter in username field, submit login form upon enter in password field -* Changed default path to OctoPrint executable in included init-script to `/usr/local/bin/octoprint` (the default when - installing via `python setup.py install`) - -### Bug Fixes - -* Properly calculate time deltas (forgot to factor in days) -* [#35](https://github.com/foosel/OctoPrint/issues/35) - GCODE viewer has been modularized, options are now functional -* [#337](https://github.com/foosel/OctoPrint/issues/337) - Also recognize `--iknowwhatimdoing` when running as daemon -* [#357](https://github.com/foosel/OctoPrint/issues/357) - Do not run GCODE analyzer when a print is ongoing -* [#381](https://github.com/foosel/OctoPrint/issues/381) - Only list those SD files that have an ASCII filename -* Fixed a race condition that could occur when pressing "Print" (File not opened yet, but attempt to read from it) -* [#398](https://github.com/foosel/OctoPrint/issues/398) - Fixed interfering options in GCODE viewer -* [#399](https://github.com/foosel/OctoPrint/issues/399) & [360](https://github.com/foosel/OctoPrint/issues/360) - Leave - bed temperature unset when not detected (instead of dying a horrible death) -* [#492](https://github.com/foosel/OctoPrint/issues/492) - Fixed a race condition which could lead to an attempt to read - from an already closed serial port, causing an error to be displayed to the user -* [#257](https://github.com/foosel/OctoPrint/issues/257) - Logging in on mobile devices should now work -* [#476](https://github.com/foosel/OctoPrint/issues/476) - Also update the metadata correctly when an analysis finishes -* Various fixes of bugs in newly introduced features and improvements: - - [#314](https://github.com/foosel/OctoPrint/issues/314) - Use G28 for homing (G1 was copy and paste error) - - [#317](https://github.com/foosel/OctoPrint/issues/317) - Fixed "load and print" function - - [#326](https://github.com/foosel/OctoPrint/issues/326) - Fixed refresh of SD file list - - [#338](https://github.com/foosel/OctoPrint/issues/338) - Refetch file list when deleting a file - - [#339](https://github.com/foosel/OctoPrint/issues/339) - More error resilience when handling temperature offset data from the backend - - [#345](https://github.com/foosel/OctoPrint/issues/345) - Also recognize such temperature reports that do not contain a `T:` but a `T0:` - - [#377](https://github.com/foosel/OctoPrint/pull/377) - URLs in API examples fixed - - [#378](https://github.com/foosel/OctoPrint/pull/378) - Fixed crash of API call when `getStartTime()` returns None - - [#379](https://github.com/foosel/OctoPrint/pull/379) - Corrected response code for connection success - - [#414](https://github.com/foosel/OctoPrint/pull/414) - Fix style attribute for Actual column header - -([Commits](https://github.com/foosel/OctoPrint/compare/1.0.0...1.1.0)) - -## 1.0.0 (2014-06-22) - -First release with new versioning scheme. +See [the releases on Github](https://github.com/OctoPrint/OctoPrint/releases) for detailed changelogs for all releases +and release candidates. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..ba199875d2 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1 @@ +OctoPrint's Code of Conduct can be found at [octoprint.org/conduct/](https://octoprint.org/conduct/). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 74b8be4fb5..51c1e7d19a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,50 +21,46 @@ or **[creating pull requests](#pull-requests)**. ## Issues, Tickets, however you may call them -Please read the following short instructions fully and follow them. You can +Please read the following instructions fully and follow them. You can help the project tremendously this way: not only do you help the maintainers to **address problems in a timely manner** but also keep it possible for them to **fix bugs, add new and improve on existing functionality** instead of doing nothing but ticket management. -![Ticket flow chart](http://i.imgur.com/qYSZyuw.png) +![Ticket flow chart](https://i.imgur.com/cw19qHX.png) -- **[Read the FAQ](https://github.com/foosel/OctoPrint/wiki/FAQ)** +- **[Read the FAQ](https://faq.octoprint.org)** - If you want to report a **bug**, [read "How to file a bug report" below](#how-to-file-a-bug-report) and *[use the provided template](#what-should-i-include-in-a-ticket)*. You do not need to do anything else with your ticket. - If you want to post a **feature request** or a **documentation request**, add `[Request]` to your issue's title (e.g. `[Request] Awesome new feature`). A question on how to run/change/setup something is **not** what qualifies as a request here, use the - [Mailinglist](https://groups.google.com/group/octoprint) or the - [Google+ Community](https://plus.google.com/communities/102771308349328485741) for + [community forum at community.octoprint.org](https://community.octoprint.org) for such support issues. - If you are a **developer** that wants to brainstorm a pull request or possible - changes to the plugin system, add [Brainstorming] to your issue's title (e.g. - `[Brainstorming] New plugin hook for doing some cool stuff`). + changes to the plugin system, please get in touch on the + [community forum at community.octoprint.org](https://community.octoprint.org/c/development). - If you need **support**, have a **question** or some **other reason** that doesn't fit any of the above categories, the issue tracker is not the right place. - Consult the [Mailinglist](https://groups.google.com/group/octoprint) or the - [Google+ Community](https://plus.google.com/communities/102771308349328485741) instead. + Consult the [community forum at community.octoprint.org](https://community.octoprint.org/c/support) instead. No matter what kind of ticket you create, never mix two or more "ticket reasons" into one ticket: One ticket per bug, request, brainstorming thread please. ----- - -**Note**: A bot is in place that monitors new tickets, automatically -categorizes them and checks new bug reports for usage of the provided template. -That bot will only bother you if you open a ticket that appears to be a bug (no -`[Request]` or `[Brainstorming]` in the title) without the template, and it -will do that only to ensure that all information needed to solve the issue is -available for the maintainers to directly start tackling that problem. - ----- +> 👉 **Note** +> +> A bot is in place that monitors new tickets, automatically +> categorizes them and checks new bug reports for usage of the provided template. +> That bot will only bother you if you open a ticket that appears to be a bug (no +> `[Request]` in the title) without the complete template, and it +> will do that only to ensure that all information needed to solve the issue is +> available for the maintainers to directly start tackling that problem. ## How to file a bug report If you encounter an issue with OctoPrint, you are welcome to -[submit a bug report](https://github.com/foosel/OctoPrint/issues/new). +[submit a bug report](https://github.com/OctoPrint/OctoPrint/issues/new?template=bug_report.yml). Before you do that for the first time though please take a moment to read the following section *completely* and also follow the instructions in the @@ -72,22 +68,25 @@ following section *completely* and also follow the instructions in the ### What should I do before submitting a bug report? -1. **Make sure you are at the right location**. This is the Github repository +1. **Make sure you are at the right location**. This is the bug tracker of the official version of OctoPrint, which is the 3D print server and corresponding web interface itself. - **This is not the Github respository of OctoPi**, which is the preconfigured + **OctoPrint doesn't manage your network connection or your webcam nor + can it fix your printer not getting detected as a serial interface** - + if you have any kinds of problems with that, get in touch on the + [community forum](https://community.octoprint.org). + + **This is not the bug tracker of OctoPi**, which is the preconfigured Raspberry Pi image including OctoPrint among other things - that one can be found - [here](https://github.com/guysoft/OctoPi). Please note that while we do have - some entries regarding OctoPi in the FAQ, any bugs should be reported in the - [proper bug tracker](https://github.com/guysoft/OctoPi/issues) which - again - - is not here. + [here](https://github.com/guysoft/OctoPi). If you have any kind of specific + issue with how the OctoPi system is setup, go there please. - **This is also not the Github repository of any OctoPrint Plugins you + **This is also not the bug tracker of any OctoPrint Plugins you might have installed**. Report any issues with those in their corresponding bug tracker (probably linked to from the plugin's homepage). - Finally, **this is also not the right issue tracker if you are running a + Finally, **this is also not the right bug tracker if you are running a forked version of OctoPrint**. Seek help for such unofficial versions from the people maintaining them instead. @@ -101,7 +100,7 @@ following section *completely* and also follow the instructions in the more about safe mode in the [docs](http://docs.octoprint.org/en/master/features/safemode.html). You might also want to try the current development version of OctoPrint - (if you aren't already). Refer to the [FAQ](https://github.com/foosel/OctoPrint/wiki/FAQ) + (if you aren't already). Refer to the [FAQ](https://faq.octoprint.org) for information on how to do this. 3. The problem still exists? Then please **look through the @@ -111,15 +110,15 @@ following section *completely* and also follow the instructions in the Sorting through duplicates of the same issue sometimes causes more work than fixing it. Take the time to filter through possible duplicates and be really sure that your problem definitely is a new one. Try more than one search query - (e.g. do not only search for "webcam" if you happen to run into an issue - with your webcam, also search for "timelapse" etc). Do not only read the subject lines + (e.g. do not only search for "timelapse" if you happen to run into an issue + with your webcam, also search for "recording" etc). Do not only read the subject lines of tickets that look like they might be related, but also read the ticket itself! **Very important:** Please make absolutely sure that if you find a bug that looks like it is the same as your's, it actually behaves the same as your's. E.g. if someone gives steps to reproduce his bug that looks like your's, reproduce the bug like that if possible, and only add a "me too" if you actually can reproduce the same - issue. Also **provide all information** as [described below](#what-should-i-include-in-a-bug-report) + issue. Also **provide all information like Systeminfo Bundle, additional logs & versions, different reproduction steps** and whatever was additionally requested over the course of the ticket even if you "only" add to an existing ticket. The more information available regarding a bug, the higher the chances of reproducing and solving it. But "me too" on an actually unrelated ticket @@ -127,134 +126,144 @@ following section *completely* and also follow the instructions in the there's now also a [red herring](https://en.wikipedia.org/wiki/Red_herring) interfering - so please be very diligent here! +If in doubt about any of the above - get in touch on the [community forums](https://community.octoprint.org) +instead of opening a ticket here. If you are actually running into a bug, we'll figure it out together +there. + ### What should I include in a bug report? First of all make sure your use **a descriptive title**. "It doesn't work" and similar unspecific complaints are NOT descriptive titles. -**Always use the following template** (please remove what's within `[...]`, that's -only provided here as some additional information for you), **even if only adding a -"me too" to an existing ticket**: +**Always use the following template, even if only adding a "me too" to an +existing ticket**: - #### What were you doing? +``` + - #### Version of OctoPrint +#### What were you doing? - [Can be found in the lower left corner of the web interface. ALWAYS INCLUDE.] + - [OctoPi, Linux, Windows, MacOS, something else? With version please, - OctoPi's version can be found in /etc/octopi_version] +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error - #### Printer model & used firmware incl. version + - [If applicable, always include if unsure.] +#### What did you expect to happen? - #### Browser and Version of Browser, Operating System running Browser +#### What happened instead? - [If applicable, always include if unsure.] +#### Did the same happen when running OctoPrint in safe mode? - #### Link to octoprint.log + - [On gist.github.com or pastebin.com. If applicable, always include if unsure or - reporting communication issues. Never truncate. +#### Version of OctoPrint - serial.log is usually not written due to performance reasons and must be - enabled explicitly. Provide at the very least the FULL contents of your - terminal tab at the time of the bug occurrence, even if you do not have - a serial.log.] + - #### Link to contents of Javascript console in the browser +#### Printer model & used firmware incl. version - [On gist.github.com or pastebin.com or alternatively a screenshot. If applicable - - always include if unsure or reporting UI issues. + - The Contribution Guidelines tell you where to find that.] +#### Browser and version of browser, operating system running browser - #### Screenshot(s)/video(s) showing the problem: + - [If applicable. Always include if unsure or reporting UI issues.] +#### System Info Bundle - I have read the FAQ. + -### Where can I find which version and branch I'm on? +#### Link to contents of Javascript console in the browser + + + +#### Screenshot(s)/video(s) showing the problem: + + + +I have read the FAQ. +``` -You can find out all of them by taking a look into the lower left corner of the -OctoPrint UI: +Provide the same kind and amount of information also when you are just adding on to an +existing ticket! -![Current version and git branch info in OctoPrint's UI](http://i.imgur.com/HyHMlY2.png) +### Where can I find the System Info Bundle? -If you don't have access to the UI you can find out that information via the -command line as well. Either `octoprint --version` or `python setup.py version` -in OctoPrint's folder will tell you the version of OctoPrint you are running -(note: if it doesn't then you are running a version older than 1.1.0, -*upgrade now*). A `git branch` in your OctoPrint installation folder will mark -the branch you are on with a little *. `git rev-parse HEAD` will tell you the -current commit. +Please refer to [this FAQ entry](https://community.octoprint.org/t/what-is-a-systeminfo-bundle-and-how-can-i-obtain-one/29887). + +### Where can I find which version and branch I'm on? + +Please refer to [this FAQ entry](https://community.octoprint.org/t/how-can-i-find-out-the-version-of-octoprint-or-octopi-i-am-running/204/1). ### Where can I find those log files you keep talking about? -OctoPrint by default provides two log outputs, a third one can be enabled if -more information is needed. - -One is contained in the **"Terminal" tab** within OctoPrint's UI and is a log -of the last 300 lines of communication with the printer. Please copy-paste -this *completely* somewhere (disable auto scroll to make copying the contents easier) - -e.g. http://pastebin.com or http://gist.github.com - and include a link in -your bug report. - -There is also **OctoPrint's application log file** or in short `octoprint.log`, -which is by default located at `~/.octoprint/logs/octoprint.log` on Linux, -`%APPDATA%\OctoPrint\logs\octoprint.log` on Windows and -`~/Library/Application Support/OctoPrint/logs/octoprint.log` on MacOS. You can -also access it directly through OctoPrint via Settings > Logs. Please -copy-paste this *completely* to pastebin or gist as well and include a link in your bug -report. - -It might happen that you are asked to provide a more **thorough log of the -communication with the printer** if you haven't already done so, the `serial.log`. -This is not written by default due to performance reasons, but you can enable -it in the settings dialog. After enabling that log, please reproduce the problem -again (connect to the printer, do whatever triggers it), then copy-paste -`~/.octoprint/logs/serial.log` (Windows: `%APPDATA%\OctoPrint\logs\serial.log`, -MacOS: `~/Library/Application Support/OctoPrint/logs/serial.log`) to pastebin -or gist and include the link in the bug report. - -You might also be asked to provide a log with an increased log level. You can -find information on how to do just that in the -[docs](http://docs.octoprint.org/en/master/configuration/logging_yaml.html). +Please refer to [this FAQ entry](https://community.octoprint.org/t/where-can-i-find-octoprints-and-octopis-log-files/299/1). ### Where can I find my browser's error console? @@ -262,9 +271,10 @@ See [How to open the Javascript Console in different browsers](https://webmaster ## Setting up a development environment -See [the corresponding chapter in the documentation](http://docs.octoprint.org/en/master/development/index.html#setting-up-a-development-environment). -This also includes information on how to run the test suite and how to build -the documentation. +See [the corresponding chapter in the documentation](https://docs.octoprint.org/en/master/development/environment.html). +This also includes information on how to run the test suite and how to build +the documentation, the bundled virtual printer plugin and OctoPrint's versioning +and branching strategy. ## Pull requests @@ -272,11 +282,13 @@ the documentation. consider if it wouldn't be better suited for a plugin.** As a general rule of thumb, any feature that is only of interest to a small sub group should be moved into a plugin. If the current plugin system doesn't allow you to - implement your feature as a plugin, create a "Brainstorming" ticket to get + implement your feature as a plugin, please get in touch on the + [forums](https://community.octoprint.org/c/development) to get the discussion going on how best to solve *this* in OctoPrint's plugin system - maybe that's the actual PR you have been waiting for to contribute :) 2. If you plan to make **any large or otherwise disruptive changes to the - code or appearance, please open a "Brainstorming" ticket first** so + code or appearance, please get in touch on the + [forums](https://community.octoprint.org/c/development)** first so that we can determine if it's a good time for your specific pull request. It might be that we're currently in the process of making heavy changes to the code locations you'd target as well, or your @@ -284,18 +296,20 @@ the documentation. just cause unnecessary work and frustration for everyone or possibly get the PR rejected. 3. Create your pull request **from a custom branch** on your end (e.g. - `dev/myNewFeature`)[1]. + `improve/myNewFeature`)[1]. 4. Create your pull request **only against the `maintenance` or `devel` branch**: - * if it's a bug fix for a bug in the current stable version: `maintenance` branch - * otherwise: `devel` branch + * if it's a bug fix for a bug in the current stable version, an improvement of existing functionality or a + *small* backwards compatible new feature (e.g. a new hook, a new config flag, ...): `maintenance` branch + * if it's a bigger backwards compatible new feature: please [get in touch](https://community.octoprint.org/c/development) first to avoid + wasting work that doesn't match the current direction of the project or implement as a plugin. + * if it's any breaking backwards incompatible change: `devel` branch. In case of big changes, [get in touch](https://community.octoprint.org/c/development) first. 5. Create **one pull request per feature/bug fix**. 6. Make sure there are **only relevant changes** included in your PR. No changes to unrelated files, no additional files that don't belong (e.g. commits of your full virtual environment). Make sure your PR consists **ideally of only one commit** (use git's rebase and squash functionality). 7. Make sure you **follow the current coding style**. This means: - * Tabs instead of spaces in the Python files[2] - * Spaces instead of tabs in the JavaScript sources + * Spaces for indenting and alignment, indentation width 4. * English language (code, variables, comments, ...) * Comments where necessary: Tell *why* the code does something like it does it, structure your code @@ -306,29 +320,41 @@ the documentation. from experiments). 8. Ensure your changes **pass the existing unit tests**. PRs that break those cannot be accepted. You can run the unit tests locally (after - [initial development environment setup with "develop" dependencies](http://docs.octoprint.org/en/master/development/index.html#setting-up-a-development-environment)) + [initial development environment setup with "develop" dependencies](https://docs.octoprint.org/en/master/development/environment.html)) by running - + ``` - nosetests --with-doctest + pytest ``` - - in the OctoPrint checkout folder. A [travis build](https://travis-ci.org/foosel/OctoPrint) - is also setup so that if the tests should fail, your PR will be marked + + in the OctoPrint checkout folder. An [automatic build workflow](https://github.com/OctoPrint/OctoPrint/actions?query=workflow%3ABuild) + is also setup so that if the tests should fail, your PR will be marked accordingly. -9. **Test your changes thoroughly**. That also means testing with usage - scenarios you don't normally use, e.g. if you only use access control, test - without and vice versa. If you only test with your printer, test with the - virtual printer and vice versa. State in your pull request how you tested - your changes. Ideally **add unit tests** - OctoPrint severely lacks in that - department, but we are trying to change that, so any new code already covered - with a test suite helps a lot! -10. In your pull request's description, **state what your pull request does**, +9. Run the **pre-commit check suite** against your changes. You can run that (after + [initial development environment setup with "develop" dependencies](https://docs.octoprint.org/en/master/development/environment.html)) + by running + + ``` + pre-commit run --hook-stage manual --all-files + ``` + + in the OctoPrint checkout folder. If you install the pre-commit hooks via + `pre-commit install` (which you really should!) this will even be taken care of for you prior to committing. + + An [automatic build workflow](https://github.com/OctoPrint/OctoPrint/actions?query=workflow%3ABuild) + is in place that will run these checks - if they fail your PR will be marked accordingly. +10. **Test your changes thoroughly**. That also means testing with usage + scenarios you don't normally use. If you only test with your printer, test with the + virtual printer and vice versa. State in your pull request how you tested + your changes. Ideally **add unit tests** - OctoPrint severely lacks in that + department, but we are trying to change that, so any new code already covered + with a test suite helps a lot! +11. In your pull request's description, **state what your pull request does**, as in, what feature does it implement, what bug does it fix. The more thoroughly you explain your intent behind the PR here, the higher the chances it will get merged fast. There is a template provided below that can help you here. -11. Don't forget to **add yourself to the [AUTHORS](./AUTHORS.md) +12. Don't forget to **add yourself to the [AUTHORS](./AUTHORS.md) file** :) Template to use for Pull Request descriptions: @@ -347,81 +373,13 @@ Template to use for Pull Request descriptions: #### Further notes ``` +## How is OctoPrint versioned? + +See [the corresponding chapter in the documentation](https://docs.octoprint.org/en/master/development/versioning.html). + ## What do the branches mean? -There are three main branches in OctoPrint: - - * `master`: The master branch always contains the current stable release. It - is *only* updated on new releases. Will have a version number following - the scheme `..` (e.g. `1.2.9`) or - if it's absolutely necessary to - add a commit after release to this branch - `...post` - (e.g. `1.2.9.post1`). - * `maintenance`: Improvements and fixes of the current release that make up - the next release go here. More or less continuously updated. You can consider - this a preview of the next release version. It should be very stable at all - times. Anything you spot in here helps tremendously with getting a rock solid - next stable release, so if you want to help out development, running the - `maintenance` branch and reporting back anything you find is a very good way - to do that. Will usually have a version number following the scheme - `...dev` for an OctoPrint version of `..` - (e.g. `1.2.10.dev12`). - * `devel`: Ongoing development of new features that will go into the next bigger - release (MINOR version number increases) will happen on this branch. Usually - kept stable, sometimes stuff can break though or lose backwards compatibility - temporarily. Can be considered the "bleeding edge". All PRs should target - *this* branch. Important improvements and fixes from PRs here are backported to - `maintenance` as needed. Will usually have a version number following the - scheme `..0.dev` for a current OctoPrint version - of `..` (e.g. `1.3.0.dev123`). - * `rc/maintenance`: This branch is reserved for future releases that have graduated from - the `maintenance` branch and are now being pushed on the "Maintenance" - pre release channel for further testing. Version number follows the scheme - `..rc` (e.g. `1.2.9rc1`). - * `staging/maintenance`: Any preparation for potential follow-up RCs takes place here. - Version number follows the scheme `..rc.dev` (e.g. - `1.2.9rc1.dev3`) for a current Maintenance RC of `..rc`. - * `rc/devel`: This branch is reserved for future releases that have graduated from - the `devel` branch and are now being pushed on the "Devel" pre release channel - for further testing. Version number follows the scheme `..0rc` (e.g. `1.3.0rc1`) - for a current stable OctoPrint version of `..`. - * `staging/devel`: Any preparation for potential follow-up Devel RCs takes place - here. Version number follows the scheme `..0rc.dev` (e.g. - `1.3.0rc1.dev12`) for a current Devel RC of `..0rc`. - -Additionally, from time to time you might see other branches pop up in the repository. -Those usually have one of the following prefixes: - - * `fix/...`: Fixes under development that are to be merged into the `maintenance` - and `devel` branches. - * `improve/...`: Improvements under development that are to be merged into the - `maintenance` and `devel` branches. - * `dev/...` or `feature/...`: New functionality under development that is to be merged - into the `devel` branch. - -There is also the `gh-pages` branch, which holds OctoPrint's web page, and a few -older development branches that are slowly being migrated or deleted. - -## How OctoPrint is versioned - -OctoPrint follows the [semantic versioning scheme](http://semver.org/) of **MAJOR.MINOR.PATCH**. - -The **PATCH** version number is the one increasing most often due to OctoPrint's maintenance releases. -Releases that only change the patch number indicate that they contain bug fixes and small improvements -of existing functionality. Example: 1.2.8 to 1.2.9. - -The **MINOR** version number increases with releases that add a lot of new functionality and -large features. Example: 1.2.x to 1.3.0. - -Finally, the **MAJOR** version number increases if there are breaking API changes that concern any of the -documented interfaces (REST API, plugin interfaces, ...). So far this hasn't happened. Example: 1.x.y to 2.0.0. - -OctoPrint's version numbers are automatically generated using [versioneer](https://github.com/warner/python-versioneer) -and depend on the selected git branch, nearest git tag and commits. The generated version number -should always be [PEP440](https://www.python.org/dev/peps/pep-0440/) compatible. Unless a git tag -is used for version number determination, the version number will also contain the git hash within -the local version identifier to allow for an exact determination of the active code base -(e.g. `1.2.9.dev68+g46c7a9c`). Additionally, instances with active uncommitted changes will contain -`.dirty` in the local version identifier. +See [the corresponding chapter in the documentation](https://docs.octoprint.org/en/master/development/branches.html). ## History @@ -445,14 +403,20 @@ the local version identifier to allow for an exact determination of the active c by OctoPrint itself and not a misbehaving plugin. * 2017-03-27: Added safe mode section to ticket template. * 2017-11-22: Added note on how to run the unit tests + * 2018-03-15: Link to new community forum and some clarifications re bug + reporting + * 2018-03-29: "Where to find version numbers" is now located on the FAQ + * 2018-10-18: Allow PRs against `maintenance` branch for improvements and small + new features, suggest getting in touch on the forum for larger changes + * 2020-08-10: Update versioning scheme and PR instructions + * 2020-09-23: Move branch & versioning into development docs + * 2020-10-07: Introduce `pre-commit` + * 2021-02-04: Issue forms! \o/ + * 2021-03-04: Correct issue forms link + * 2021-04-27: Systeminfo Bundles! \o/ ## Footnotes * [1] - If you are wondering why, the problem is that anything that you add to your PR's branch will also become part of your PR, so if you create a PR from your version of `devel` chances are high you'll add changes to the PR that do not belong to the PR. - * [2] - Yes, we know that this goes against PEP-8. OctoPrint started out as - a fork of Cura and hence stuck to the coding style found therein. Changing - it now would make the history and especially `git blame` completely - unusable, so for now we'll have to deal with it (this decision might be - revisited in the future). diff --git a/LICENSE b/LICENSE.txt similarity index 100% rename from LICENSE rename to LICENSE.txt diff --git a/README.md b/README.md index 1571a3308b..8f9cc1b84f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,32 @@ -# OctoPrint - -[![GitHub version](https://badge.fury.io/gh/foosel%2FOctoPrint.svg)](https://badge.fury.io/gh/foosel%2FOctoPrint) +

OctoPrint's logo

+ +

OctoPrint

+ +

+ GitHub release + PyPI + Build status + Community Forum + Discord + Twitter Follow + Contributor Covenant + Code style: black + Code style: prettier + Imports: isort + pre-commit +

OctoPrint provides a snappy web interface for controlling consumer 3D printers. It is Free Software and released under the [GNU Affero General Public License V3](http://www.gnu.org/licenses/agpl.html). Its website can be found at [octoprint.org](https://octoprint.org/?utm_source=github&utm_medium=readme). +The community forum is available at [community.octoprint.org](https://community.octoprint.org/?utm_source=github&utm_medium=readme). It also serves as central knowledge base. + +An invite to the Discord server can be found at [discord.octoprint.org](https://discord.octoprint.org). + +The FAQ can be accessed by following [faq.octoprint.org](https://faq.octoprint.org/?utm_source=github&utm_medium=readme). + The documentation is located at [docs.octoprint.org](http://docs.octoprint.org). The official plugin repository can be reached at [plugins.octoprint.org](https://plugins.octoprint.org/?utm_source=github&utm_medium=readme). @@ -14,73 +34,66 @@ The official plugin repository can be reached at [plugins.octoprint.org](https:/ **OctoPrint's development wouldn't be possible without the [financial support by its community](https://octoprint.org/support-octoprint/?utm_source=github&utm_medium=readme). If you enjoy OctoPrint, please consider becoming a regular supporter!** -![Screenshot](http://i.imgur.com/dF3noFp.png) +![Screenshot](https://octoprint.org/assets/img/screenshot-readme.png) You are currently looking at the source code repository of OctoPrint. If you already installed it (e.g. by using the Raspberry Pi targeted distribution [OctoPi](https://github.com/guysoft/OctoPi)) and only -want to find out how to use it, [the documentation](http://docs.octoprint.org/) and [the public wiki](https://github.com/foosel/OctoPrint/wiki) -might be of more interest for you. You might also want to subscribe to [the mailing list](https://groups.google.com/group/octoprint) -or the [G+ Community](https://plus.google.com/communities/102771308349328485741) where there are other active users who might be +want to find out how to use it, [the documentation](http://docs.octoprint.org/) might be of more interest for you. You might also want to subscribe to join +[the community forum at community.octoprint.org](https://community.octoprint.org) where there are other active users who might be able to help you with any questions you might have. ## Contributing Contributions of all kinds are welcome, not only in the form of code but also with regards to the -[official documentation](http://docs.octoprint.org/) or [the public wiki](https://github.com/foosel/OctoPrint/wiki), support -of other users in the [bug tracker](https://github.com/foosel/OctoPrint/issues), -[the Mailinglist](https://groups.google.com/group/octoprint) or -[the G+ Community](https://plus.google.com/communities/102771308349328485741) and also [financially](https://octoprint.org/support-octoprint/?utm_source=github&utm_medium=readme). +[official documentation](http://docs.octoprint.org/), debugging help +in the [bug tracker](https://github.com/OctoPrint/OctoPrint/issues), support of other users on +[the community forum at community.octoprint.org](https://community.octoprint.org) or +[the official discord at discord.octoprint.org](https://discord.octoprint.org) +and also [financially](https://octoprint.org/support-octoprint/?utm_source=github&utm_medium=readme). If you think something is bad about OctoPrint or its documentation the way it is, please help in any way to make it better instead of just complaining about it -- this is an Open Source Project after all :) For information about how to go about submitting bug reports or pull requests, please see the project's -[Contribution Guidelines](https://github.com/foosel/OctoPrint/blob/master/CONTRIBUTING.md). +[Contribution Guidelines](https://github.com/OctoPrint/OctoPrint/blob/master/CONTRIBUTING.md). ## Installation Installation instructions for installing from source for different operating -systems can be found [on the wiki](https://github.com/foosel/OctoPrint/wiki#assorted-guides). +systems can be found [on the forum](https://community.octoprint.org/tags/c/support/guides/15/setup). -If you want to run OctoPrint on a Raspberry Pi, you might want to take a look at [OctoPi](https://github.com/guysoft/OctoPi) +If you want to run OctoPrint on a Raspberry Pi, you really should take a look at [OctoPi](https://github.com/guysoft/OctoPi) which is a custom SD card image that includes OctoPrint plus dependencies. The generic steps that should basically be done regardless of operating system and runtime environment are the following (as *regular user*, please keep your hands *off* of the `sudo` command here!) - this assumes -you already have Python 2.7, pip and virtualenv set up on your system: +you already have Python 2.7, 3.6 or 3.7, pip and virtualenv and their dependencies set up on your system: -1. Checkout OctoPrint: `git clone https://github.com/foosel/OctoPrint.git` -2. Change into the OctoPrint folder: `cd OctoPrint` -3. Create a user-owned virtual environment therein: `virtualenv venv` -4. Install OctoPrint *into that virtual environment*: `./venv/bin/python setup.py install` +1. Create a user-owned virtual environment therein: `virtualenv venv`. If you want to specify a specific python + to use instead of whatever version your system defaults to, you can also explicitly require that via the `--python` + parameter, e.g. `virtualenv --python=python3 venv`. +2. Install OctoPrint *into that virtual environment*: `./venv/bin/pip install OctoPrint` You may then start the OctoPrint server via `/path/to/OctoPrint/venv/bin/octoprint`, see [Usage](#usage) for details. After installation, please make sure you follow the first-run wizard and set up -access control as necessary. If you want to not only be notified about new -releases, but also be able to automatically upgrade to them from within -OctoPrint, take a look [at the documentation of the Software Update Plugin](https://github.com/foosel/OctoPrint/wiki/Plugin:-Software-Update#making-octoprint-updateable-on-existing-installations) -and at its settings. +access control as necessary. ## Dependencies OctoPrint depends on a few python modules to do its job. Those are automatically installed when installing -OctoPrint via `setup.py`: +OctoPrint via `pip`. - python setup.py install - -You should do this every time after pulling from the repository, since the dependencies may have changed. - -OctoPrint currently only supports Python 2.7. +OctoPrint currently supports Python 2.7, 3.6 and 3.7. ## Usage -Running the `setup.py` script via +Running the pip install via - python setup.py install + pip install OctoPrint installs the `octoprint` script in your Python installation's scripts folder (which, depending on whether you installed OctoPrint globally or into a virtual env, will be in your `PATH` or not). The @@ -133,5 +146,8 @@ be edited from OctoPrint's settings dialog. ## Special Thanks -Cross-browser testing services are kindly provided by [BrowserStack](http://www.browserstack.com/). -Profiling is done with the help of [PyVmMonitor](http://www.pyvmmonitor.com). +Cross-browser testing services are kindly provided by [BrowserStack](https://www.browserstack.com/). + +Profiling is done with the help of [PyVmMonitor](https://www.pyvmmonitor.com). + +Error tracking is powered and sponsored by [Sentry](https://sentry.io). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..79b94cdd95 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +## Supported Versions + +Always update to the latest version of OctoPrint to keep up with security patches. + +## Reporting a Vulnerability + +Email to security@octoprint.org. + +For the sake of the userbase of OctoPrint please always disclose responsibly and with a 90+ day window. diff --git a/SUPPORTERS.md b/SUPPORTERS.md index 29c55a3e39..cb53fca62a 100644 --- a/SUPPORTERS.md +++ b/SUPPORTERS.md @@ -1,61 +1,83 @@ -# Supporters +# Supporters Development of this version of OctoPrint wouldn't have been possible without -[financial support by the community](http://octoprint.org/support-octoprint/) - +[the financial support by the community](https://support.octoprint.org) - thanks to everyone who contributed! -## Patreon Patrons +## Patrons & Sponsors - * 3D Moniak - * Aleph Objects, Inc. - * Andrew Moorby + * 3D-TECH + * 3DPrinterOS * Arnljot Arntsen - * BEEVERYCREATIVE + * Artillery 3D + * Ash King + * BigTreeTech * Boris Hussein * Brad Jackson - * Brian E. Tyler + * BuildBee * Christian Petropolis - * COLLE+McVOY - * CreativeTools - * D Brian Kimmel + * Christian Wolf + * Christian Würthner + * Christoph Sigrist + * Creality3D + * David Bounds * DeltaMaker 3D Printers - * E3D BigBox + * Dennis Breining + * Douglas Floyd + * Dr. Frank Wester + * Eric Betts * Ernesto Martinez - * F. Kunsmann - * Frank Sander - * Gary Deen - * Gary N McKinney + * Franziska Kunsmann * George Robles - * günter weber - * James Seigel - * Jason Lawrence + * Greg Holloway + * Hog Duske + * Itay Shem-tov + * James Mackay * Jeff Moe - * Josh Daniels + * Jeremy Cole + * Joshua Wills + * Julian Melo + * Justin Kaufman * Kaile Riser - * Kale Stedman * Kazuhiro Ogura + * Kelly C McNiece + * Kenneth Jiang + * Kurt Wubbels + * Kyle Menigoz + * LA 3D Printer Repair + * Lachlan Bell + * Lee Dohm + * Lefteris Lertas + * LulzBot * Makespace Madrid - * Marcus Ackermann * Mark Walker * Michael Aumock + * Mike Kershaw * Miles Flavel - * mitchell hirsch - * Mohammed khorakiwala - * Noe Ruiz - * Patrick McGinnis - * Peter Schmehl - * PRINT3Dforum.com + * Mosaic Manufacturing + * Nat Friedman + * Neil R. Goldberg + * Norman Jaffe + * Peopoly + * Pete Barnwell + * Raise3D * Randy C. Will - * Roger Strolz - * Roy Cortes - * Samer Najia - * Simon Hallam + * Ranjib Dey + * Richard McGuire + * Richard Michaud + * Richard Stocks + * Robert Gusek + * Ronald Griehsler + * Sebastien Andrivet * Stefan Krister - * Stephane Schittly + * Steve Dougherty + * Steve Thompson * Sven Mueller - * Symbiotic Devices - * Thomas Hatley - * Timeshell.ca - * Trent Shumay + * TH3D + * The Spaghetti Detective + * TJ Horner + * Ulderico Cirello + * Ulrich Kempken + * Yehuda Katz -and 1062 more wonderful people pledging on the [Patreon campaign](https://patreon.com/foosel)! \ No newline at end of file +and 2285 more wonderful people pledging on the [Patreon campaign](https://patreon.com/foosel) or via [GitHub Sponsors](https://github.com/users/foosel/sponsorship)! diff --git a/THIRDPARTYLICENSES.md b/THIRDPARTYLICENSES.md index 6f0dd0d3eb..6b72d46390 100644 --- a/THIRDPARTYLICENSES.md +++ b/THIRDPARTYLICENSES.md @@ -4,13 +4,16 @@ * [AVLTree](https://gist.github.com/viking/2424106) (modified): GPL, LGPL (dual licensed) * [Babel JavaScript Support](https://github.com/mitsuhiko/babel/blob/master/contrib/babel.js): BSD + * [@babel/polyfill](https://babeljs.io/docs/en/babel-polyfill/): MIT * [Bootstrap](http://getbootstrap.com/): Apache License 2.0 * [Bootstrap Modal](http://jschr.github.io/bootstrap-modal/): Apache License 2.0 * [Bootstrap Slider Knockout Binding](https://github.com/cosminstefanxp/bootstrap-slider-knockout-binding): MIT * [Bootstrap Slider](http://seiyria.com/bootstrap-slider/): Apache License 2.0 * [Bootstrap Tabdrop](http://www.eyecon.ro/bootstrap-tabdrop): Apache License 2.0 + * [css-element-queries](https://github.com/marcj/css-element-queries): MIT * [Detect Mobile Browser](http://detectmobilebrowsers.com/): Public Domain * [Flot](http://www.flotcharts.org/): MIT + * [HLS.js](https://github.com/video-dev/hls.js): Apache License 2.0 * [JavaScript MD5](https://github.com/blueimp/JavaScript-MD5): MIT * [JQuery](http://jquery.com/): MIT * [JQuery Bootstrap Wizard](http://github.com/VinceG/twitter-bootstrap-wizard): MIT, GPL (dual licensed) @@ -23,12 +26,14 @@ * [LESS](http://lesscss.org): Apache License 2.0 * [lodash](https://lodash.com): MIT * [Loglevel](https://github.com/pimterry/loglevel): MIT + * [md5.js](https://blueimp.github.io/JavaScript-MD5/): MIT * [Modernizr](http://modernizr.com): MIT * [Moment.js](http://momentjs.com/): MIT * [PNotify](http://sciactive.com/pnotify/): GPL, LGPL, MPL (triple licensed) * [pusher.color.js](http://cache.preserve.io/5g18q0pw/index.html) (original link dead): MIT * [SockJS](https://github.com/sockjs/sockjs-client): MIT * [sprintf-js](http://alexei.ro/): BSD + * [UAParser.js](https://faisalman.github.io/ua-parser-js/): MIT ## Server @@ -36,20 +41,22 @@ * [Awesome-Slugify](https://pypi.python.org/pypi/awesome-slugify): GPLv3 * [chainmap](https://bitbucket.org/jeunice/chainmap): Python * [Click](http://click.pocoo.org/): BSD - * [dateutil](https://dateutil.readthedocs.io/): BSD * [emoji](https://github.com/carpedm20/emoji/): BSD * [feedparser](https://github.com/kurtmckee/feedparser): BSD + * [filetype](https://h2non.github.io/filetype.py/): MIT * [Flask](http://flask.pocoo.org/): BSD * [Flask-Assets](http://github.com/miracle2k/flask-assets): BSD * [Flask-Babel](http://github.com/mitsuhiko/flask-babel): BSD * [Flask-Login](https://github.com/maxcountryman/flask-login): MIT * [Flask-Markdown](http://github.com/dcolish/flask-markdown): BSD * [Flask-Principal](http://packages.python.org/Flask-Principal/): MIT + * [frozendict](https://github.com/slezica/python-frozendict): MIT * [future](https://python-future.org/): MIT * [futures](https://github.com/agronholm/pythonfutures): Python * [monotonic](https://github.com/atdt/monotonic): Apache License 2.0 * [netaddr](https://github.com/drkjam/netaddr/): BSD * [netifaces](https://bitbucket.org/al45tair/netifaces): MIT + * [pathvalidate](https://pathvalidate.readthedocs.io/en/latest/index.html): MIT * [pkginfo](http://pypi.python.org/pypi/pkginfo/): Python * [psutil](https://github.com/giampaolo/psutil): BSD * [pylru](https://github.com/jlhutch/pylru): GPLv2 @@ -58,20 +65,25 @@ * [requests](http://python-requests.org/): Apache License 2.0 * [rsa](http://stuvel.eu/rsa): Apache License 2.0 * [sarge](http://sarge.readthedocs.org/): BSD + * [sentry-sdk](https://github.com/getsentry/sentry-python): BSD * [scandir](https://github.com/benhoyt/scandir): BSD * [semantic_version](https://github.com/rbarrois/python-semanticversion): BSD * [SockJS-Tornado](http://github.com/mrjoes/sockjs-tornado/): MIT * [Tornado](http://www.tornadoweb.org/): Apache License 2.0 + * [typing](https://pypi.org/project/typing/): Python * [watchdog](http://github.com/gorakhargosh/watchdog): Apache License 2.0 * [websocket-client](https://github.com/liris/websocket-client): LGPLv3 * [wrapt](http://wrapt.readthedocs.org/): BSD ## Development (testing, documentation generation, etc) + * [cookiecutter](https://github.com/cookiecutter/cookiecutter): BSD * [ddt](https://github.com/txels/ddt): MIT + * [flake8](https://gitlab.com/pycqa/flake8): MIT * [mock](https://github.com/testing-cabal/mock): BSD - * [nose](http://pythonhosted.org/nose/): LGPL - * [pypandoc](https://github.com/bebraw/pypandoc): MIT + * [pyinstrument](https://github.com/joerick/pyinstrument): BSD + * [pytest](https://docs.pytest.org/en/latest/): MIT + * [pytest-doctest-custom](http://github.com/danilobellini/pytest-doctest-custom): MIT * [Sphinx](http://sphinx-doc.org/): BSD * [sphinxcontrib-httpdomain](https://bitbucket.org/birkenfeld/sphinx-contrib/src/default/httpdomain/): BSD * [sphinxcontrib-mermad](https://github.com/mgaitan/sphinxcontrib-mermaid): BSD diff --git a/babel.cfg b/babel.cfg index 014eb25ded..9117d82ce6 100644 --- a/babel.cfg +++ b/babel.cfg @@ -1,8 +1,15 @@ [python: src/octoprint/**.py] + [jinja2: src/octoprint/templates/**.jinja2] +silent=false +extensions=jinja2.ext.autoescape, jinja2.ext.with_, webassets.ext.jinja2.AssetsExtension, jinja2.ext.do, octoprint.util.jinja.trycatch + [jinja2: src/octoprint/plugins/**.jinja2] -extensions=jinja2.ext.autoescape, jinja2.ext.with_, webassets.ext.jinja2.AssetsExtension +silent=false +extensions=jinja2.ext.autoescape, jinja2.ext.with_, webassets.ext.jinja2.AssetsExtension, jinja2.ext.do, octoprint.util.jinja.trycatch [javascript: src/octoprint/static/js/app/**.js] +extract_messages = gettext, ngettext + [javascript: src/octoprint/plugins/**.js] extract_messages = gettext, ngettext diff --git a/black.toml b/black.toml new file mode 100644 index 0000000000..f60471a5df --- /dev/null +++ b/black.toml @@ -0,0 +1,21 @@ +[tool.black] +line-length = 90 +include = ''' + +( + ( + src + | tests + | .github + ).*\.pyi?$ +)|( + setup\.py +) +''' +exclude = ''' + +( + src/octoprint/vendor # we want to still be able to apply patches from upstream w/o + # match issues +) +''' diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index b7ebd1e784..0000000000 --- a/docs/Makefile +++ /dev/null @@ -1,177 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/OctoPrint.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/OctoPrint.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/OctoPrint" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/OctoPrint" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/_static/mermaid.min.js b/docs/_static/mermaid.min.js new file mode 100644 index 0000000000..12d77d0a60 --- /dev/null +++ b/docs/_static/mermaid.min.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.mermaid=e():t.mermaid=e()}("undefined"!=typeof self?self:this,(function(){return function(t){var e={};function n(r){if(e[r])return e[r].exports;var i=e[r]={i:r,l:!1,exports:{}};return t[r].call(i.exports,i,i.exports,n),i.l=!0,i.exports}return n.m=t,n.c=e,n.d=function(t,e,r){n.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:r})},n.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},n.t=function(t,e){if(1&e&&(t=n(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var i in t)n.d(r,i,function(e){return t[e]}.bind(null,i));return r},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},n.p="",n(n.s=383)}([function(t,e,n){"use strict";n.r(e);var r=function(t,e){return te?1:t>=e?0:NaN},i=function(t){var e;return 1===t.length&&(e=t,t=function(t,n){return r(e(t),n)}),{left:function(e,n,r,i){for(null==r&&(r=0),null==i&&(i=e.length);r>>1;t(e[a],n)<0?r=a+1:i=a}return r},right:function(e,n,r,i){for(null==r&&(r=0),null==i&&(i=e.length);r>>1;t(e[a],n)>0?i=a:r=a+1}return r}}};var a=i(r),o=a.right,s=a.left,c=o,u=function(t,e){null==e&&(e=l);for(var n=0,r=t.length-1,i=t[0],a=new Array(r<0?0:r);nt?1:e>=t?0:NaN},d=function(t){return null===t?NaN:+t},p=function(t,e){var n,r,i=t.length,a=0,o=-1,s=0,c=0;if(null==e)for(;++o1)return c/(a-1)},y=function(t,e){var n=p(t,e);return n?Math.sqrt(n):n},g=function(t,e){var n,r,i,a=t.length,o=-1;if(null==e){for(;++o=n)for(r=i=n;++on&&(r=n),i=n)for(r=i=n;++on&&(r=n),i0)return[t];if((r=e0)for(t=Math.ceil(t/o),e=Math.floor(e/o),a=new Array(i=Math.ceil(e-t+1));++s=0?(a>=w?10:a>=E?5:a>=T?2:1)*Math.pow(10,i):-Math.pow(10,-i)/(a>=w?10:a>=E?5:a>=T?2:1)}function A(t,e,n){var r=Math.abs(e-t)/Math.max(0,n),i=Math.pow(10,Math.floor(Math.log(r)/Math.LN10)),a=r/i;return a>=w?i*=10:a>=E?i*=5:a>=T&&(i*=2),eh;)f.pop(),--d;var p,y=new Array(d+1);for(i=0;i<=d;++i)(p=y[i]=[]).x0=i>0?f[i-1]:l,p.x1=i=1)return+n(t[r-1],r-1,t);var r,i=(r-1)*e,a=Math.floor(i),o=+n(t[a],a,t);return o+(+n(t[a+1],a+1,t)-o)*(i-a)}},N=function(t,e,n){return t=b.call(t,d).sort(r),Math.ceil((n-e)/(2*(D(t,.75)-D(t,.25))*Math.pow(t.length,-1/3)))},B=function(t,e,n){return Math.ceil((n-e)/(3.5*y(t)*Math.pow(t.length,-1/3)))},L=function(t,e){var n,r,i=t.length,a=-1;if(null==e){for(;++a=n)for(r=n;++ar&&(r=n)}else for(;++a=n)for(r=n;++ar&&(r=n);return r},P=function(t,e){var n,r=t.length,i=r,a=-1,o=0;if(null==e)for(;++a=0;)for(e=(r=t[i]).length;--e>=0;)n[--o]=r[e];return n},j=function(t,e){var n,r,i=t.length,a=-1;if(null==e){for(;++a=n)for(r=n;++an&&(r=n)}else for(;++a=n)for(r=n;++an&&(r=n);return r},R=function(t,e){for(var n=e.length,r=new Array(n);n--;)r[n]=t[e[n]];return r},Y=function(t,e){if(n=t.length){var n,i,a=0,o=0,s=t[o];for(null==e&&(e=r);++a=0&&(n=t.slice(r+1),t=t.slice(0,r)),t&&!e.hasOwnProperty(t))throw new Error("unknown type: "+t);return{type:t,name:n}}))}function ct(t,e){for(var n,r=0,i=t.length;r0)for(var n,r,i=new Array(n),a=0;ae?1:t>=e?0:NaN}var _t="http://www.w3.org/1999/xhtml",kt={svg:"http://www.w3.org/2000/svg",xhtml:_t,xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"},wt=function(t){var e=t+="",n=e.indexOf(":");return n>=0&&"xmlns"!==(e=t.slice(0,n))&&(t=t.slice(n+1)),kt.hasOwnProperty(e)?{space:kt[e],local:t}:t};function Et(t){return function(){this.removeAttribute(t)}}function Tt(t){return function(){this.removeAttributeNS(t.space,t.local)}}function Ct(t,e){return function(){this.setAttribute(t,e)}}function St(t,e){return function(){this.setAttributeNS(t.space,t.local,e)}}function At(t,e){return function(){var n=e.apply(this,arguments);null==n?this.removeAttribute(t):this.setAttribute(t,n)}}function Mt(t,e){return function(){var n=e.apply(this,arguments);null==n?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,n)}}var Ot=function(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView};function Dt(t){return function(){this.style.removeProperty(t)}}function Nt(t,e,n){return function(){this.style.setProperty(t,e,n)}}function Bt(t,e,n){return function(){var r=e.apply(this,arguments);null==r?this.style.removeProperty(t):this.style.setProperty(t,r,n)}}function Lt(t,e){return t.style.getPropertyValue(e)||Ot(t).getComputedStyle(t,null).getPropertyValue(e)}function Pt(t){return function(){delete this[t]}}function It(t,e){return function(){this[t]=e}}function Ft(t,e){return function(){var n=e.apply(this,arguments);null==n?delete this[t]:this[t]=n}}function jt(t){return t.trim().split(/^|\s+/)}function Rt(t){return t.classList||new Yt(t)}function Yt(t){this._node=t,this._names=jt(t.getAttribute("class")||"")}function zt(t,e){for(var n=Rt(t),r=-1,i=e.length;++r=0&&(this._names.splice(e,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};function Vt(){this.textContent=""}function Gt(t){return function(){this.textContent=t}}function qt(t){return function(){var e=t.apply(this,arguments);this.textContent=null==e?"":e}}function Xt(){this.innerHTML=""}function Zt(t){return function(){this.innerHTML=t}}function Jt(t){return function(){var e=t.apply(this,arguments);this.innerHTML=null==e?"":e}}function Kt(){this.nextSibling&&this.parentNode.appendChild(this)}function Qt(){this.previousSibling&&this.parentNode.insertBefore(this,this.parentNode.firstChild)}function te(t){return function(){var e=this.ownerDocument,n=this.namespaceURI;return n===_t&&e.documentElement.namespaceURI===_t?e.createElement(t):e.createElementNS(n,t)}}function ee(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}var ne=function(t){var e=wt(t);return(e.local?ee:te)(e)};function re(){return null}function ie(){var t=this.parentNode;t&&t.removeChild(this)}function ae(){var t=this.cloneNode(!1),e=this.parentNode;return e?e.insertBefore(t,this.nextSibling):t}function oe(){var t=this.cloneNode(!0),e=this.parentNode;return e?e.insertBefore(t,this.nextSibling):t}var se={},ce=null;"undefined"!=typeof document&&("onmouseenter"in document.documentElement||(se={mouseenter:"mouseover",mouseleave:"mouseout"}));function ue(t,e,n){return t=le(t,e,n),function(e){var n=e.relatedTarget;n&&(n===this||8&n.compareDocumentPosition(this))||t.call(this,e)}}function le(t,e,n){return function(r){var i=ce;ce=r;try{t.call(this,this.__data__,e,n)}finally{ce=i}}}function he(t){return t.trim().split(/^|\s+/).map((function(t){var e="",n=t.indexOf(".");return n>=0&&(e=t.slice(n+1),t=t.slice(0,n)),{type:t,name:e}}))}function fe(t){return function(){var e=this.__on;if(e){for(var n,r=0,i=-1,a=e.length;r=_&&(_=x+1);!(b=v[_])&&++_=0;)(r=i[a])&&(o&&4^r.compareDocumentPosition(o)&&o.parentNode.insertBefore(r,o),o=r);return this},sort:function(t){function e(e,n){return e&&n?t(e.__data__,n.__data__):!e-!n}t||(t=xt);for(var n=this._groups,r=n.length,i=new Array(r),a=0;a1?this.each((null==e?Dt:"function"==typeof e?Bt:Nt)(t,e,null==n?"":n)):Lt(this.node(),t)},property:function(t,e){return arguments.length>1?this.each((null==e?Pt:"function"==typeof e?Ft:It)(t,e)):this.node()[t]},classed:function(t,e){var n=jt(t+"");if(arguments.length<2){for(var r=Rt(this.node()),i=-1,a=n.length;++i>8&15|e>>4&240,e>>4&15|240&e,(15&e)<<4|15&e,1):8===n?new qe(e>>24&255,e>>16&255,e>>8&255,(255&e)/255):4===n?new qe(e>>12&15|e>>8&240,e>>8&15|e>>4&240,e>>4&15|240&e,((15&e)<<4|15&e)/255):null):(e=Le.exec(t))?new qe(e[1],e[2],e[3],1):(e=Pe.exec(t))?new qe(255*e[1]/100,255*e[2]/100,255*e[3]/100,1):(e=Ie.exec(t))?He(e[1],e[2],e[3],e[4]):(e=Fe.exec(t))?He(255*e[1]/100,255*e[2]/100,255*e[3]/100,e[4]):(e=je.exec(t))?Ke(e[1],e[2]/100,e[3]/100,1):(e=Re.exec(t))?Ke(e[1],e[2]/100,e[3]/100,e[4]):Ye.hasOwnProperty(t)?We(Ye[t]):"transparent"===t?new qe(NaN,NaN,NaN,0):null}function We(t){return new qe(t>>16&255,t>>8&255,255&t,1)}function He(t,e,n,r){return r<=0&&(t=e=n=NaN),new qe(t,e,n,r)}function Ve(t){return t instanceof Me||(t=$e(t)),t?new qe((t=t.rgb()).r,t.g,t.b,t.opacity):new qe}function Ge(t,e,n,r){return 1===arguments.length?Ve(t):new qe(t,e,n,null==r?1:r)}function qe(t,e,n,r){this.r=+t,this.g=+e,this.b=+n,this.opacity=+r}function Xe(){return"#"+Je(this.r)+Je(this.g)+Je(this.b)}function Ze(){var t=this.opacity;return(1===(t=isNaN(t)?1:Math.max(0,Math.min(1,t)))?"rgb(":"rgba(")+Math.max(0,Math.min(255,Math.round(this.r)||0))+", "+Math.max(0,Math.min(255,Math.round(this.g)||0))+", "+Math.max(0,Math.min(255,Math.round(this.b)||0))+(1===t?")":", "+t+")")}function Je(t){return((t=Math.max(0,Math.min(255,Math.round(t)||0)))<16?"0":"")+t.toString(16)}function Ke(t,e,n,r){return r<=0?t=e=n=NaN:n<=0||n>=1?t=e=NaN:e<=0&&(t=NaN),new en(t,e,n,r)}function Qe(t){if(t instanceof en)return new en(t.h,t.s,t.l,t.opacity);if(t instanceof Me||(t=$e(t)),!t)return new en;if(t instanceof en)return t;var e=(t=t.rgb()).r/255,n=t.g/255,r=t.b/255,i=Math.min(e,n,r),a=Math.max(e,n,r),o=NaN,s=a-i,c=(a+i)/2;return s?(o=e===a?(n-r)/s+6*(n0&&c<1?0:o,new en(o,s,c,t.opacity)}function tn(t,e,n,r){return 1===arguments.length?Qe(t):new en(t,e,n,null==r?1:r)}function en(t,e,n,r){this.h=+t,this.s=+e,this.l=+n,this.opacity=+r}function nn(t,e,n){return 255*(t<60?e+(n-e)*t/60:t<180?n:t<240?e+(n-e)*(240-t)/60:e)}function rn(t,e,n,r,i){var a=t*t,o=a*t;return((1-3*t+3*a-o)*e+(4-6*a+3*o)*n+(1+3*t+3*a-3*o)*r+o*i)/6}Se(Me,$e,{copy:function(t){return Object.assign(new this.constructor,this,t)},displayable:function(){return this.rgb().displayable()},hex:ze,formatHex:ze,formatHsl:function(){return Qe(this).formatHsl()},formatRgb:Ue,toString:Ue}),Se(qe,Ge,Ae(Me,{brighter:function(t){return t=null==t?1/.7:Math.pow(1/.7,t),new qe(this.r*t,this.g*t,this.b*t,this.opacity)},darker:function(t){return t=null==t?.7:Math.pow(.7,t),new qe(this.r*t,this.g*t,this.b*t,this.opacity)},rgb:function(){return this},displayable:function(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Xe,formatHex:Xe,formatRgb:Ze,toString:Ze})),Se(en,tn,Ae(Me,{brighter:function(t){return t=null==t?1/.7:Math.pow(1/.7,t),new en(this.h,this.s,this.l*t,this.opacity)},darker:function(t){return t=null==t?.7:Math.pow(.7,t),new en(this.h,this.s,this.l*t,this.opacity)},rgb:function(){var t=this.h%360+360*(this.h<0),e=isNaN(t)||isNaN(this.s)?0:this.s,n=this.l,r=n+(n<.5?n:1-n)*e,i=2*n-r;return new qe(nn(t>=240?t-240:t+120,i,r),nn(t,i,r),nn(t<120?t+240:t-120,i,r),this.opacity)},displayable:function(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl:function(){var t=this.opacity;return(1===(t=isNaN(t)?1:Math.max(0,Math.min(1,t)))?"hsl(":"hsla(")+(this.h||0)+", "+100*(this.s||0)+"%, "+100*(this.l||0)+"%"+(1===t?")":", "+t+")")}}));var an=function(t){var e=t.length-1;return function(n){var r=n<=0?n=0:n>=1?(n=1,e-1):Math.floor(n*e),i=t[r],a=t[r+1],o=r>0?t[r-1]:2*i-a,s=r180||n<-180?n-360*Math.round(n/360):n):sn(isNaN(t)?e:t)}function ln(t){return 1==(t=+t)?hn:function(e,n){return n-e?function(t,e,n){return t=Math.pow(t,n),e=Math.pow(e,n)-t,n=1/n,function(r){return Math.pow(t+r*e,n)}}(e,n,t):sn(isNaN(e)?n:e)}}function hn(t,e){var n=e-t;return n?cn(t,n):sn(isNaN(t)?e:t)}var fn=function t(e){var n=ln(e);function r(t,e){var r=n((t=Ge(t)).r,(e=Ge(e)).r),i=n(t.g,e.g),a=n(t.b,e.b),o=hn(t.opacity,e.opacity);return function(e){return t.r=r(e),t.g=i(e),t.b=a(e),t.opacity=o(e),t+""}}return r.gamma=t,r}(1);function dn(t){return function(e){var n,r,i=e.length,a=new Array(i),o=new Array(i),s=new Array(i);for(n=0;na&&(i=e.slice(a,i),s[o]?s[o]+=i:s[++o]=i),(n=n[0])===(r=r[0])?s[o]?s[o]+=r:s[++o]=r:(s[++o]=null,c.push({i:o,x:_n(n,r)})),a=En.lastIndex;return a=0&&e._call.call(null,t),e=e._next;--Bn}function Vn(){Fn=(In=Rn.now())+jn,Bn=Ln=0;try{Hn()}finally{Bn=0,function(){var t,e,n=Tn,r=1/0;for(;n;)n._call?(r>n._time&&(r=n._time),t=n,n=n._next):(e=n._next,n._next=null,n=t?t._next=e:Tn=e);Cn=t,qn(r)}(),Fn=0}}function Gn(){var t=Rn.now(),e=t-In;e>1e3&&(jn-=e,In=t)}function qn(t){Bn||(Ln&&(Ln=clearTimeout(Ln)),t-Fn>24?(t<1/0&&(Ln=setTimeout(Vn,t-Rn.now()-jn)),Pn&&(Pn=clearInterval(Pn))):(Pn||(In=Rn.now(),Pn=setInterval(Gn,1e3)),Bn=1,Yn(Vn)))}$n.prototype=Wn.prototype={constructor:$n,restart:function(t,e,n){if("function"!=typeof t)throw new TypeError("callback is not a function");n=(null==n?zn():+n)+(null==e?0:+e),this._next||Cn===this||(Cn?Cn._next=this:Tn=this,Cn=this),this._call=t,this._time=n,qn()},stop:function(){this._call&&(this._call=null,this._time=1/0,qn())}};var Xn=function(t,e,n){var r=new $n;return e=null==e?0:+e,r.restart((function(n){r.stop(),t(n+e)}),e,n),r},Zn=lt("start","end","cancel","interrupt"),Jn=[],Kn=function(t,e,n,r,i,a){var o=t.__transition;if(o){if(n in o)return}else t.__transition={};!function(t,e,n){var r,i=t.__transition;function a(c){var u,l,h,f;if(1!==n.state)return s();for(u in i)if((f=i[u]).name===n.name){if(3===f.state)return Xn(a);4===f.state?(f.state=6,f.timer.stop(),f.on.call("interrupt",t,t.__data__,f.index,f.group),delete i[u]):+u0)throw new Error("too late; already scheduled");return n}function tr(t,e){var n=er(t,e);if(n.state>3)throw new Error("too late; already running");return n}function er(t,e){var n=t.__transition;if(!n||!(n=n[e]))throw new Error("transition not found");return n}var nr,rr,ir,ar,or=function(t,e){var n,r,i,a=t.__transition,o=!0;if(a){for(i in e=null==e?null:e+"",a)(n=a[i]).name===e?(r=n.state>2&&n.state<5,n.state=6,n.timer.stop(),n.on.call(r?"interrupt":"cancel",t,t.__data__,n.index,n.group),delete a[i]):o=!1;o&&delete t.__transition}},sr=180/Math.PI,cr={translateX:0,translateY:0,rotate:0,skewX:0,scaleX:1,scaleY:1},ur=function(t,e,n,r,i,a){var o,s,c;return(o=Math.sqrt(t*t+e*e))&&(t/=o,e/=o),(c=t*n+e*r)&&(n-=t*c,r-=e*c),(s=Math.sqrt(n*n+r*r))&&(n/=s,r/=s,c/=s),t*r180?e+=360:e-t>180&&(t+=360),a.push({i:n.push(i(n)+"rotate(",null,r)-2,x:_n(t,e)})):e&&n.push(i(n)+"rotate("+e+r)}(a.rotate,o.rotate,s,c),function(t,e,n,a){t!==e?a.push({i:n.push(i(n)+"skewX(",null,r)-2,x:_n(t,e)}):e&&n.push(i(n)+"skewX("+e+r)}(a.skewX,o.skewX,s,c),function(t,e,n,r,a,o){if(t!==n||e!==r){var s=a.push(i(a)+"scale(",null,",",null,")");o.push({i:s-4,x:_n(t,n)},{i:s-2,x:_n(e,r)})}else 1===n&&1===r||a.push(i(a)+"scale("+n+","+r+")")}(a.scaleX,a.scaleY,o.scaleX,o.scaleY,s,c),a=o=null,function(t){for(var e,n=-1,r=c.length;++n=0&&(t=t.slice(0,e)),!t||"start"===t}))}(e)?Qn:tr;return function(){var o=a(this,t),s=o.on;s!==r&&(i=(r=s).copy()).on(e,n),o.on=i}}var Br=_e.prototype.constructor;function Lr(t){return function(){this.style.removeProperty(t)}}function Pr(t,e,n){return function(r){this.style.setProperty(t,e.call(this,r),n)}}function Ir(t,e,n){var r,i;function a(){var a=e.apply(this,arguments);return a!==i&&(r=(i=a)&&Pr(t,a,n)),r}return a._value=e,a}function Fr(t){return function(e){this.textContent=t.call(this,e)}}function jr(t){var e,n;function r(){var r=t.apply(this,arguments);return r!==n&&(e=(n=r)&&Fr(r)),e}return r._value=t,r}var Rr=0;function Yr(t,e,n,r){this._groups=t,this._parents=e,this._name=n,this._id=r}function zr(t){return _e().transition(t)}function Ur(){return++Rr}var $r=_e.prototype;function Wr(t){return t*t*t}function Hr(t){return--t*t*t+1}function Vr(t){return((t*=2)<=1?t*t*t:(t-=2)*t*t+2)/2}Yr.prototype=zr.prototype={constructor:Yr,select:function(t){var e=this._name,n=this._id;"function"!=typeof t&&(t=ft(t));for(var r=this._groups,i=r.length,a=new Array(i),o=0;o1&&n.name===e)return new Yr([[t]],Xr,e,+r);return null},Jr=function(t){return function(){return t}},Kr=function(t,e,n){this.target=t,this.type=e,this.selection=n};function Qr(){ce.stopImmediatePropagation()}var ti=function(){ce.preventDefault(),ce.stopImmediatePropagation()},ei={name:"drag"},ni={name:"space"},ri={name:"handle"},ii={name:"center"};function ai(t){return[+t[0],+t[1]]}function oi(t){return[ai(t[0]),ai(t[1])]}function si(t){return function(e){return Dn(e,ce.touches,t)}}var ci={name:"x",handles:["w","e"].map(gi),input:function(t,e){return null==t?null:[[+t[0],e[0][1]],[+t[1],e[1][1]]]},output:function(t){return t&&[t[0][0],t[1][0]]}},ui={name:"y",handles:["n","s"].map(gi),input:function(t,e){return null==t?null:[[e[0][0],+t[0]],[e[1][0],+t[1]]]},output:function(t){return t&&[t[0][1],t[1][1]]}},li={name:"xy",handles:["n","w","e","s","nw","ne","sw","se"].map(gi),input:function(t){return null==t?null:oi(t)},output:function(t){return t}},hi={overlay:"crosshair",selection:"move",n:"ns-resize",e:"ew-resize",s:"ns-resize",w:"ew-resize",nw:"nwse-resize",ne:"nesw-resize",se:"nwse-resize",sw:"nesw-resize"},fi={e:"w",w:"e",nw:"ne",ne:"nw",se:"sw",sw:"se"},di={n:"s",s:"n",nw:"sw",ne:"se",se:"ne",sw:"nw"},pi={overlay:1,selection:1,n:null,e:1,s:null,w:-1,nw:-1,ne:1,se:1,sw:-1},yi={overlay:1,selection:1,n:-1,e:null,s:1,w:null,nw:-1,ne:-1,se:1,sw:1};function gi(t){return{type:t}}function vi(){return!ce.ctrlKey&&!ce.button}function mi(){var t=this.ownerSVGElement||this;return t.hasAttribute("viewBox")?[[(t=t.viewBox.baseVal).x,t.y],[t.x+t.width,t.y+t.height]]:[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]}function bi(){return navigator.maxTouchPoints||"ontouchstart"in this}function xi(t){for(;!t.__brush;)if(!(t=t.parentNode))return;return t.__brush}function _i(t){return t[0][0]===t[1][0]||t[0][1]===t[1][1]}function ki(t){var e=t.__brush;return e?e.dim.output(e.selection):null}function wi(){return Ci(ci)}function Ei(){return Ci(ui)}var Ti=function(){return Ci(li)};function Ci(t){var e,n=mi,r=vi,i=bi,a=!0,o=lt("start","brush","end"),s=6;function c(e){var n=e.property("__brush",y).selectAll(".overlay").data([gi("overlay")]);n.enter().append("rect").attr("class","overlay").attr("pointer-events","all").attr("cursor",hi.overlay).merge(n).each((function(){var t=xi(this).extent;ke(this).attr("x",t[0][0]).attr("y",t[0][1]).attr("width",t[1][0]-t[0][0]).attr("height",t[1][1]-t[0][1])})),e.selectAll(".selection").data([gi("selection")]).enter().append("rect").attr("class","selection").attr("cursor",hi.selection).attr("fill","#777").attr("fill-opacity",.3).attr("stroke","#fff").attr("shape-rendering","crispEdges");var r=e.selectAll(".handle").data(t.handles,(function(t){return t.type}));r.exit().remove(),r.enter().append("rect").attr("class",(function(t){return"handle handle--"+t.type})).attr("cursor",(function(t){return hi[t.type]})),e.each(u).attr("fill","none").attr("pointer-events","all").on("mousedown.brush",f).filter(i).on("touchstart.brush",f).on("touchmove.brush",d).on("touchend.brush touchcancel.brush",p).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function u(){var t=ke(this),e=xi(this).selection;e?(t.selectAll(".selection").style("display",null).attr("x",e[0][0]).attr("y",e[0][1]).attr("width",e[1][0]-e[0][0]).attr("height",e[1][1]-e[0][1]),t.selectAll(".handle").style("display",null).attr("x",(function(t){return"e"===t.type[t.type.length-1]?e[1][0]-s/2:e[0][0]-s/2})).attr("y",(function(t){return"s"===t.type[0]?e[1][1]-s/2:e[0][1]-s/2})).attr("width",(function(t){return"n"===t.type||"s"===t.type?e[1][0]-e[0][0]+s:s})).attr("height",(function(t){return"e"===t.type||"w"===t.type?e[1][1]-e[0][1]+s:s}))):t.selectAll(".selection,.handle").style("display","none").attr("x",null).attr("y",null).attr("width",null).attr("height",null)}function l(t,e,n){return!n&&t.__brush.emitter||new h(t,e)}function h(t,e){this.that=t,this.args=e,this.state=t.__brush,this.active=0}function f(){if((!e||ce.touches)&&r.apply(this,arguments)){var n,i,o,s,c,h,f,d,p,y,g,v=this,m=ce.target.__data__.type,b="selection"===(a&&ce.metaKey?m="overlay":m)?ei:a&&ce.altKey?ii:ri,x=t===ui?null:pi[m],_=t===ci?null:yi[m],k=xi(v),w=k.extent,E=k.selection,T=w[0][0],C=w[0][1],S=w[1][0],A=w[1][1],M=0,O=0,D=x&&_&&a&&ce.shiftKey,N=ce.touches?si(ce.changedTouches[0].identifier):Nn,B=N(v),L=B,P=l(v,arguments,!0).beforestart();"overlay"===m?(E&&(p=!0),k.selection=E=[[n=t===ui?T:B[0],o=t===ci?C:B[1]],[c=t===ui?S:n,f=t===ci?A:o]]):(n=E[0][0],o=E[0][1],c=E[1][0],f=E[1][1]),i=n,s=o,h=c,d=f;var I=ke(v).attr("pointer-events","none"),F=I.selectAll(".overlay").attr("cursor",hi[m]);if(ce.touches)P.moved=R,P.ended=z;else{var j=ke(ce.view).on("mousemove.brush",R,!0).on("mouseup.brush",z,!0);a&&j.on("keydown.brush",U,!0).on("keyup.brush",$,!0),Te(ce.view)}Qr(),or(v),u.call(v),P.start()}function R(){var t=N(v);!D||y||g||(Math.abs(t[0]-L[0])>Math.abs(t[1]-L[1])?g=!0:y=!0),L=t,p=!0,ti(),Y()}function Y(){var t;switch(M=L[0]-B[0],O=L[1]-B[1],b){case ni:case ei:x&&(M=Math.max(T-n,Math.min(S-c,M)),i=n+M,h=c+M),_&&(O=Math.max(C-o,Math.min(A-f,O)),s=o+O,d=f+O);break;case ri:x<0?(M=Math.max(T-n,Math.min(S-n,M)),i=n+M,h=c):x>0&&(M=Math.max(T-c,Math.min(S-c,M)),i=n,h=c+M),_<0?(O=Math.max(C-o,Math.min(A-o,O)),s=o+O,d=f):_>0&&(O=Math.max(C-f,Math.min(A-f,O)),s=o,d=f+O);break;case ii:x&&(i=Math.max(T,Math.min(S,n-M*x)),h=Math.max(T,Math.min(S,c+M*x))),_&&(s=Math.max(C,Math.min(A,o-O*_)),d=Math.max(C,Math.min(A,f+O*_)))}h0&&(n=i-M),_<0?f=d-O:_>0&&(o=s-O),b=ni,F.attr("cursor",hi.selection),Y());break;default:return}ti()}function $(){switch(ce.keyCode){case 16:D&&(y=g=D=!1,Y());break;case 18:b===ii&&(x<0?c=h:x>0&&(n=i),_<0?f=d:_>0&&(o=s),b=ri,Y());break;case 32:b===ni&&(ce.altKey?(x&&(c=h-M*x,n=i+M*x),_&&(f=d-O*_,o=s+O*_),b=ii):(x<0?c=h:x>0&&(n=i),_<0?f=d:_>0&&(o=s),b=ri),F.attr("cursor",hi[m]),Y());break;default:return}ti()}}function d(){l(this,arguments).moved()}function p(){l(this,arguments).ended()}function y(){var e=this.__brush||{selection:null};return e.extent=oi(n.apply(this,arguments)),e.dim=t,e}return c.move=function(e,n){e.selection?e.on("start.brush",(function(){l(this,arguments).beforestart().start()})).on("interrupt.brush end.brush",(function(){l(this,arguments).end()})).tween("brush",(function(){var e=this,r=e.__brush,i=l(e,arguments),a=r.selection,o=t.input("function"==typeof n?n.apply(this,arguments):n,r.extent),s=An(a,o);function c(t){r.selection=1===t&&null===o?null:s(t),u.call(e),i.brush()}return null!==a&&null!==o?c:c(1)})):e.each((function(){var e=this,r=arguments,i=e.__brush,a=t.input("function"==typeof n?n.apply(e,r):n,i.extent),o=l(e,r).beforestart();or(e),i.selection=null===a?null:a,u.call(e),o.start().brush().end()}))},c.clear=function(t){c.move(t,null)},h.prototype={beforestart:function(){return 1==++this.active&&(this.state.emitter=this,this.starting=!0),this},start:function(){return this.starting?(this.starting=!1,this.emit("start")):this.emit("brush"),this},brush:function(){return this.emit("brush"),this},end:function(){return 0==--this.active&&(delete this.state.emitter,this.emit("end")),this},emit:function(e){pe(new Kr(c,e,t.output(this.state.selection)),o.apply,o,[e,this.that,this.args])}},c.extent=function(t){return arguments.length?(n="function"==typeof t?t:Jr(oi(t)),c):n},c.filter=function(t){return arguments.length?(r="function"==typeof t?t:Jr(!!t),c):r},c.touchable=function(t){return arguments.length?(i="function"==typeof t?t:Jr(!!t),c):i},c.handleSize=function(t){return arguments.length?(s=+t,c):s},c.keyModifiers=function(t){return arguments.length?(a=!!t,c):a},c.on=function(){var t=o.on.apply(o,arguments);return t===o?c:t},c}var Si=Math.cos,Ai=Math.sin,Mi=Math.PI,Oi=Mi/2,Di=2*Mi,Ni=Math.max;function Bi(t){return function(e,n){return t(e.source.value+e.target.value,n.source.value+n.target.value)}}var Li=function(){var t=0,e=null,n=null,r=null;function i(i){var a,o,s,c,u,l,h=i.length,f=[],d=k(h),p=[],y=[],g=y.groups=new Array(h),v=new Array(h*h);for(a=0,u=-1;++u1e-6)if(Math.abs(l*s-c*u)>1e-6&&i){var f=n-a,d=r-o,p=s*s+c*c,y=f*f+d*d,g=Math.sqrt(p),v=Math.sqrt(h),m=i*Math.tan((Fi-Math.acos((p+h-y)/(2*g*v)))/2),b=m/v,x=m/g;Math.abs(b-1)>1e-6&&(this._+="L"+(t+b*u)+","+(e+b*l)),this._+="A"+i+","+i+",0,0,"+ +(l*f>u*d)+","+(this._x1=t+x*s)+","+(this._y1=e+x*c)}else this._+="L"+(this._x1=t)+","+(this._y1=e);else;},arc:function(t,e,n,r,i,a){t=+t,e=+e,a=!!a;var o=(n=+n)*Math.cos(r),s=n*Math.sin(r),c=t+o,u=e+s,l=1^a,h=a?r-i:i-r;if(n<0)throw new Error("negative radius: "+n);null===this._x1?this._+="M"+c+","+u:(Math.abs(this._x1-c)>1e-6||Math.abs(this._y1-u)>1e-6)&&(this._+="L"+c+","+u),n&&(h<0&&(h=h%ji+ji),h>Ri?this._+="A"+n+","+n+",0,1,"+l+","+(t-o)+","+(e-s)+"A"+n+","+n+",0,1,"+l+","+(this._x1=c)+","+(this._y1=u):h>1e-6&&(this._+="A"+n+","+n+",0,"+ +(h>=Fi)+","+l+","+(this._x1=t+n*Math.cos(i))+","+(this._y1=e+n*Math.sin(i))))},rect:function(t,e,n,r){this._+="M"+(this._x0=this._x1=+t)+","+(this._y0=this._y1=+e)+"h"+ +n+"v"+ +r+"h"+-n+"Z"},toString:function(){return this._}};var Ui=zi;function $i(t){return t.source}function Wi(t){return t.target}function Hi(t){return t.radius}function Vi(t){return t.startAngle}function Gi(t){return t.endAngle}var qi=function(){var t=$i,e=Wi,n=Hi,r=Vi,i=Gi,a=null;function o(){var o,s=Pi.call(arguments),c=t.apply(this,s),u=e.apply(this,s),l=+n.apply(this,(s[0]=c,s)),h=r.apply(this,s)-Oi,f=i.apply(this,s)-Oi,d=l*Si(h),p=l*Ai(h),y=+n.apply(this,(s[0]=u,s)),g=r.apply(this,s)-Oi,v=i.apply(this,s)-Oi;if(a||(a=o=Ui()),a.moveTo(d,p),a.arc(0,0,l,h,f),h===g&&f===v||(a.quadraticCurveTo(0,0,y*Si(g),y*Ai(g)),a.arc(0,0,y,g,v)),a.quadraticCurveTo(0,0,d,p),a.closePath(),o)return a=null,o+""||null}return o.radius=function(t){return arguments.length?(n="function"==typeof t?t:Ii(+t),o):n},o.startAngle=function(t){return arguments.length?(r="function"==typeof t?t:Ii(+t),o):r},o.endAngle=function(t){return arguments.length?(i="function"==typeof t?t:Ii(+t),o):i},o.source=function(e){return arguments.length?(t=e,o):t},o.target=function(t){return arguments.length?(e=t,o):e},o.context=function(t){return arguments.length?(a=null==t?null:t,o):a},o};function Xi(){}function Zi(t,e){var n=new Xi;if(t instanceof Xi)t.each((function(t,e){n.set(e,t)}));else if(Array.isArray(t)){var r,i=-1,a=t.length;if(null==e)for(;++i=r.length)return null!=t&&n.sort(t),null!=e?e(n):n;for(var c,u,l,h=-1,f=n.length,d=r[i++],p=Ji(),y=o();++hr.length)return n;var o,s=i[a-1];return null!=e&&a>=r.length?o=n.entries():(o=[],n.each((function(e,n){o.push({key:n,values:t(e,a)})}))),null!=s?o.sort((function(t,e){return s(t.key,e.key)})):o}(a(t,0,ea,na),0)},key:function(t){return r.push(t),n},sortKeys:function(t){return i[r.length-1]=t,n},sortValues:function(e){return t=e,n},rollup:function(t){return e=t,n}}};function Qi(){return{}}function ta(t,e,n){t[e]=n}function ea(){return Ji()}function na(t,e,n){t.set(e,n)}function ra(){}var ia=Ji.prototype;function aa(t,e){var n=new ra;if(t instanceof ra)t.each((function(t){n.add(t)}));else if(t){var r=-1,i=t.length;if(null==e)for(;++r6/29*(6/29)*(6/29)?Math.pow(t,1/3):t/(6/29*3*(6/29))+4/29}function va(t){return t>6/29?t*t*t:6/29*3*(6/29)*(t-4/29)}function ma(t){return 255*(t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055)}function ba(t){return(t/=255)<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function xa(t){if(t instanceof wa)return new wa(t.h,t.c,t.l,t.opacity);if(t instanceof ya||(t=fa(t)),0===t.a&&0===t.b)return new wa(NaN,0r!=d>r&&n<(f-u)*(r-l)/(d-l)+u&&(i=-i)}return i}function Fa(t,e,n){var r,i,a,o;return function(t,e,n){return(e[0]-t[0])*(n[1]-t[1])==(n[0]-t[0])*(e[1]-t[1])}(t,e,n)&&(i=t[r=+(t[0]===e[0])],a=n[r],o=e[r],i<=a&&a<=o||o<=a&&a<=i)}var ja=function(){},Ra=[[],[[[1,1.5],[.5,1]]],[[[1.5,1],[1,1.5]]],[[[1.5,1],[.5,1]]],[[[1,.5],[1.5,1]]],[[[1,1.5],[.5,1]],[[1,.5],[1.5,1]]],[[[1,.5],[1,1.5]]],[[[1,.5],[.5,1]]],[[[.5,1],[1,.5]]],[[[1,1.5],[1,.5]]],[[[.5,1],[1,.5]],[[1.5,1],[1,1.5]]],[[[1.5,1],[1,.5]]],[[[.5,1],[1.5,1]]],[[[1,1.5],[1.5,1]]],[[[.5,1],[1,1.5]]],[]],Ya=function(){var t=1,e=1,n=M,r=s;function i(t){var e=n(t);if(Array.isArray(e))e=e.slice().sort(Ba);else{var r=g(t),i=r[0],o=r[1];e=A(i,o,e),e=k(Math.floor(i/e)*e,Math.floor(o/e)*e,e)}return e.map((function(e){return a(t,e)}))}function a(n,i){var a=[],s=[];return function(n,r,i){var a,s,c,u,l,h,f=new Array,d=new Array;a=s=-1,u=n[0]>=r,Ra[u<<1].forEach(p);for(;++a=r,Ra[c|u<<1].forEach(p);Ra[u<<0].forEach(p);for(;++s=r,l=n[s*t]>=r,Ra[u<<1|l<<2].forEach(p);++a=r,h=l,l=n[s*t+a+1]>=r,Ra[c|u<<1|l<<2|h<<3].forEach(p);Ra[u|l<<3].forEach(p)}a=-1,l=n[s*t]>=r,Ra[l<<2].forEach(p);for(;++a=r,Ra[l<<2|h<<3].forEach(p);function p(t){var e,n,r=[t[0][0]+a,t[0][1]+s],c=[t[1][0]+a,t[1][1]+s],u=o(r),l=o(c);(e=d[u])?(n=f[l])?(delete d[e.end],delete f[n.start],e===n?(e.ring.push(c),i(e.ring)):f[e.start]=d[n.end]={start:e.start,end:n.end,ring:e.ring.concat(n.ring)}):(delete d[e.end],e.ring.push(c),d[e.end=l]=e):(e=f[l])?(n=d[u])?(delete f[e.start],delete d[n.end],e===n?(e.ring.push(c),i(e.ring)):f[n.start]=d[e.end]={start:n.start,end:e.end,ring:n.ring.concat(e.ring)}):(delete f[e.start],e.ring.unshift(r),f[e.start=u]=e):f[u]=d[l]={start:u,end:l,ring:[r,c]}}Ra[l<<3].forEach(p)}(n,i,(function(t){r(t,n,i),function(t){for(var e=0,n=t.length,r=t[n-1][1]*t[0][0]-t[n-1][0]*t[0][1];++e0?a.push([t]):s.push(t)})),s.forEach((function(t){for(var e,n=0,r=a.length;n0&&o0&&s0&&a>0))throw new Error("invalid size");return t=r,e=a,i},i.thresholds=function(t){return arguments.length?(n="function"==typeof t?t:Array.isArray(t)?La(Na.call(t)):La(t),i):n},i.smooth=function(t){return arguments.length?(r=t?s:ja,i):r===s},i};function za(t,e,n){for(var r=t.width,i=t.height,a=1+(n<<1),o=0;o=n&&(s>=a&&(c-=t.data[s-a+o*r]),e.data[s-n+o*r]=c/Math.min(s+1,r-1+a-s,a))}function Ua(t,e,n){for(var r=t.width,i=t.height,a=1+(n<<1),o=0;o=n&&(s>=a&&(c-=t.data[o+(s-a)*r]),e.data[o+(s-n)*r]=c/Math.min(s+1,i-1+a-s,a))}function $a(t){return t[0]}function Wa(t){return t[1]}function Ha(){return 1}var Va=function(){var t=$a,e=Wa,n=Ha,r=960,i=500,a=20,o=2,s=3*a,c=r+2*s>>o,u=i+2*s>>o,l=La(20);function h(r){var i=new Float32Array(c*u),h=new Float32Array(c*u);r.forEach((function(r,a,l){var h=+t(r,a,l)+s>>o,f=+e(r,a,l)+s>>o,d=+n(r,a,l);h>=0&&h=0&&f>o),Ua({width:c,height:u,data:h},{width:c,height:u,data:i},a>>o),za({width:c,height:u,data:i},{width:c,height:u,data:h},a>>o),Ua({width:c,height:u,data:h},{width:c,height:u,data:i},a>>o),za({width:c,height:u,data:i},{width:c,height:u,data:h},a>>o),Ua({width:c,height:u,data:h},{width:c,height:u,data:i},a>>o);var d=l(i);if(!Array.isArray(d)){var p=L(i);d=A(0,p,d),(d=k(0,Math.floor(p/d)*d,d)).shift()}return Ya().thresholds(d).size([c,u])(i).map(f)}function f(t){return t.value*=Math.pow(2,-2*o),t.coordinates.forEach(d),t}function d(t){t.forEach(p)}function p(t){t.forEach(y)}function y(t){t[0]=t[0]*Math.pow(2,o)-s,t[1]=t[1]*Math.pow(2,o)-s}function g(){return c=r+2*(s=3*a)>>o,u=i+2*s>>o,h}return h.x=function(e){return arguments.length?(t="function"==typeof e?e:La(+e),h):t},h.y=function(t){return arguments.length?(e="function"==typeof t?t:La(+t),h):e},h.weight=function(t){return arguments.length?(n="function"==typeof t?t:La(+t),h):n},h.size=function(t){if(!arguments.length)return[r,i];var e=Math.ceil(t[0]),n=Math.ceil(t[1]);if(!(e>=0||e>=0))throw new Error("invalid size");return r=e,i=n,g()},h.cellSize=function(t){if(!arguments.length)return 1<=1))throw new Error("invalid cell size");return o=Math.floor(Math.log(t)/Math.LN2),g()},h.thresholds=function(t){return arguments.length?(l="function"==typeof t?t:Array.isArray(t)?La(Na.call(t)):La(t),h):l},h.bandwidth=function(t){if(!arguments.length)return Math.sqrt(a*(a+1));if(!((t=+t)>=0))throw new Error("invalid bandwidth");return a=Math.round((Math.sqrt(4*t*t+1)-1)/2),g()},h},Ga=function(t){return function(){return t}};function qa(t,e,n,r,i,a,o,s,c,u){this.target=t,this.type=e,this.subject=n,this.identifier=r,this.active=i,this.x=a,this.y=o,this.dx=s,this.dy=c,this._=u}function Xa(){return!ce.ctrlKey&&!ce.button}function Za(){return this.parentNode}function Ja(t){return null==t?{x:ce.x,y:ce.y}:t}function Ka(){return navigator.maxTouchPoints||"ontouchstart"in this}qa.prototype.on=function(){var t=this._.on.apply(this._,arguments);return t===this._?this:t};var Qa=function(){var t,e,n,r,i=Xa,a=Za,o=Ja,s=Ka,c={},u=lt("start","drag","end"),l=0,h=0;function f(t){t.on("mousedown.drag",d).filter(s).on("touchstart.drag",g).on("touchmove.drag",v).on("touchend.drag touchcancel.drag",m).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function d(){if(!r&&i.apply(this,arguments)){var o=b("mouse",a.apply(this,arguments),Nn,this,arguments);o&&(ke(ce.view).on("mousemove.drag",p,!0).on("mouseup.drag",y,!0),Te(ce.view),we(),n=!1,t=ce.clientX,e=ce.clientY,o("start"))}}function p(){if(Ee(),!n){var r=ce.clientX-t,i=ce.clientY-e;n=r*r+i*i>h}c.mouse("drag")}function y(){ke(ce.view).on("mousemove.drag mouseup.drag",null),Ce(ce.view,n),Ee(),c.mouse("end")}function g(){if(i.apply(this,arguments)){var t,e,n=ce.changedTouches,r=a.apply(this,arguments),o=n.length;for(t=0;t9999?"+"+io(e,6):io(e,4))+"-"+io(t.getUTCMonth()+1,2)+"-"+io(t.getUTCDate(),2)+(a?"T"+io(n,2)+":"+io(r,2)+":"+io(i,2)+"."+io(a,3)+"Z":i?"T"+io(n,2)+":"+io(r,2)+":"+io(i,2)+"Z":r||n?"T"+io(n,2)+":"+io(r,2)+"Z":"")}var oo=function(t){var e=new RegExp('["'+t+"\n\r]"),n=t.charCodeAt(0);function r(t,e){var r,i=[],a=t.length,o=0,s=0,c=a<=0,u=!1;function l(){if(c)return eo;if(u)return u=!1,to;var e,r,i=o;if(34===t.charCodeAt(i)){for(;o++=a?c=!0:10===(r=t.charCodeAt(o++))?u=!0:13===r&&(u=!0,10===t.charCodeAt(o)&&++o),t.slice(i+1,e-1).replace(/""/g,'"')}for(;o=(a=(y+v)/2))?y=a:v=a,(l=n>=(o=(g+m)/2))?g=o:m=o,i=d,!(d=d[h=l<<1|u]))return i[h]=p,t;if(s=+t._x.call(null,d.data),c=+t._y.call(null,d.data),e===s&&n===c)return p.next=d,i?i[h]=p:t._root=p,t;do{i=i?i[h]=new Array(4):t._root=new Array(4),(u=e>=(a=(y+v)/2))?y=a:v=a,(l=n>=(o=(g+m)/2))?g=o:m=o}while((h=l<<1|u)==(f=(c>=o)<<1|s>=a));return i[f]=d,i[h]=p,t}var _s=function(t,e,n,r,i){this.node=t,this.x0=e,this.y0=n,this.x1=r,this.y1=i};function ks(t){return t[0]}function ws(t){return t[1]}function Es(t,e,n){var r=new Ts(null==e?ks:e,null==n?ws:n,NaN,NaN,NaN,NaN);return null==t?r:r.addAll(t)}function Ts(t,e,n,r,i,a){this._x=t,this._y=e,this._x0=n,this._y0=r,this._x1=i,this._y1=a,this._root=void 0}function Cs(t){for(var e={data:t.data},n=e;t=t.next;)n=n.next={data:t.data};return e}var Ss=Es.prototype=Ts.prototype;function As(t){return t.x+t.vx}function Ms(t){return t.y+t.vy}Ss.copy=function(){var t,e,n=new Ts(this._x,this._y,this._x0,this._y0,this._x1,this._y1),r=this._root;if(!r)return n;if(!r.length)return n._root=Cs(r),n;for(t=[{source:r,target:n._root=new Array(4)}];r=t.pop();)for(var i=0;i<4;++i)(e=r.source[i])&&(e.length?t.push({source:e,target:r.target[i]=new Array(4)}):r.target[i]=Cs(e));return n},Ss.add=function(t){var e=+this._x.call(null,t),n=+this._y.call(null,t);return xs(this.cover(e,n),e,n,t)},Ss.addAll=function(t){var e,n,r,i,a=t.length,o=new Array(a),s=new Array(a),c=1/0,u=1/0,l=-1/0,h=-1/0;for(n=0;nl&&(l=r),ih&&(h=i));if(c>l||u>h)return this;for(this.cover(c,u).cover(l,h),n=0;nt||t>=i||r>e||e>=a;)switch(s=(ef||(a=c.y0)>d||(o=c.x1)=v)<<1|t>=g)&&(c=p[p.length-1],p[p.length-1]=p[p.length-1-u],p[p.length-1-u]=c)}else{var m=t-+this._x.call(null,y.data),b=e-+this._y.call(null,y.data),x=m*m+b*b;if(x=(s=(p+g)/2))?p=s:g=s,(l=o>=(c=(y+v)/2))?y=c:v=c,e=d,!(d=d[h=l<<1|u]))return this;if(!d.length)break;(e[h+1&3]||e[h+2&3]||e[h+3&3])&&(n=e,f=h)}for(;d.data!==t;)if(r=d,!(d=d.next))return this;return(i=d.next)&&delete d.next,r?(i?r.next=i:delete r.next,this):e?(i?e[h]=i:delete e[h],(d=e[0]||e[1]||e[2]||e[3])&&d===(e[3]||e[2]||e[1]||e[0])&&!d.length&&(n?n[f]=d:this._root=d),this):(this._root=i,this)},Ss.removeAll=function(t){for(var e=0,n=t.length;ec+d||iu+d||as.index){var p=c-o.x-o.vx,y=u-o.y-o.vy,g=p*p+y*y;gt.r&&(t.r=t[e].r)}function s(){if(e){var r,i,a=e.length;for(n=new Array(a),r=0;r1?(null==n?s.remove(t):s.set(t,d(n)),e):s.get(t)},find:function(e,n,r){var i,a,o,s,c,u=0,l=t.length;for(null==r?r=1/0:r*=r,u=0;u1?(u.on(t,n),e):u.on(t)}}},js=function(){var t,e,n,r,i=ms(-30),a=1,o=1/0,s=.81;function c(r){var i,a=t.length,o=Es(t,Ls,Ps).visitAfter(l);for(n=r,i=0;i=o)){(t.data!==e||t.next)&&(0===l&&(d+=(l=bs())*l),0===h&&(d+=(h=bs())*h),d1?r[0]+r.slice(2):r,+t.slice(n+1)]},$s=function(t){return(t=Us(Math.abs(t)))?t[1]:NaN},Ws=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function Hs(t){if(!(e=Ws.exec(t)))throw new Error("invalid format: "+t);var e;return new Vs({fill:e[1],align:e[2],sign:e[3],symbol:e[4],zero:e[5],width:e[6],comma:e[7],precision:e[8]&&e[8].slice(1),trim:e[9],type:e[10]})}function Vs(t){this.fill=void 0===t.fill?" ":t.fill+"",this.align=void 0===t.align?">":t.align+"",this.sign=void 0===t.sign?"-":t.sign+"",this.symbol=void 0===t.symbol?"":t.symbol+"",this.zero=!!t.zero,this.width=void 0===t.width?void 0:+t.width,this.comma=!!t.comma,this.precision=void 0===t.precision?void 0:+t.precision,this.trim=!!t.trim,this.type=void 0===t.type?"":t.type+""}Hs.prototype=Vs.prototype,Vs.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(void 0===this.width?"":Math.max(1,0|this.width))+(this.comma?",":"")+(void 0===this.precision?"":"."+Math.max(0,0|this.precision))+(this.trim?"~":"")+this.type};var Gs,qs,Xs,Zs,Js=function(t,e){var n=Us(t,e);if(!n)return t+"";var r=n[0],i=n[1];return i<0?"0."+new Array(-i).join("0")+r:r.length>i+1?r.slice(0,i+1)+"."+r.slice(i+1):r+new Array(i-r.length+2).join("0")},Ks={"%":function(t,e){return(100*t).toFixed(e)},b:function(t){return Math.round(t).toString(2)},c:function(t){return t+""},d:function(t){return Math.round(t).toString(10)},e:function(t,e){return t.toExponential(e)},f:function(t,e){return t.toFixed(e)},g:function(t,e){return t.toPrecision(e)},o:function(t){return Math.round(t).toString(8)},p:function(t,e){return Js(100*t,e)},r:Js,s:function(t,e){var n=Us(t,e);if(!n)return t+"";var r=n[0],i=n[1],a=i-(Gs=3*Math.max(-8,Math.min(8,Math.floor(i/3))))+1,o=r.length;return a===o?r:a>o?r+new Array(a-o+1).join("0"):a>0?r.slice(0,a)+"."+r.slice(a):"0."+new Array(1-a).join("0")+Us(t,Math.max(0,e+a-1))[0]},X:function(t){return Math.round(t).toString(16).toUpperCase()},x:function(t){return Math.round(t).toString(16)}},Qs=function(t){return t},tc=Array.prototype.map,ec=["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"],nc=function(t){var e,n,r=void 0===t.grouping||void 0===t.thousands?Qs:(e=tc.call(t.grouping,Number),n=t.thousands+"",function(t,r){for(var i=t.length,a=[],o=0,s=e[0],c=0;i>0&&s>0&&(c+s+1>r&&(s=Math.max(1,r-c)),a.push(t.substring(i-=s,i+s)),!((c+=s+1)>r));)s=e[o=(o+1)%e.length];return a.reverse().join(n)}),i=void 0===t.currency?"":t.currency[0]+"",a=void 0===t.currency?"":t.currency[1]+"",o=void 0===t.decimal?".":t.decimal+"",s=void 0===t.numerals?Qs:function(t){return function(e){return e.replace(/[0-9]/g,(function(e){return t[+e]}))}}(tc.call(t.numerals,String)),c=void 0===t.percent?"%":t.percent+"",u=void 0===t.minus?"-":t.minus+"",l=void 0===t.nan?"NaN":t.nan+"";function h(t){var e=(t=Hs(t)).fill,n=t.align,h=t.sign,f=t.symbol,d=t.zero,p=t.width,y=t.comma,g=t.precision,v=t.trim,m=t.type;"n"===m?(y=!0,m="g"):Ks[m]||(void 0===g&&(g=12),v=!0,m="g"),(d||"0"===e&&"="===n)&&(d=!0,e="0",n="=");var b="$"===f?i:"#"===f&&/[boxX]/.test(m)?"0"+m.toLowerCase():"",x="$"===f?a:/[%p]/.test(m)?c:"",_=Ks[m],k=/[defgprs%]/.test(m);function w(t){var i,a,c,f=b,w=x;if("c"===m)w=_(t)+w,t="";else{var E=(t=+t)<0;if(t=isNaN(t)?l:_(Math.abs(t),g),v&&(t=function(t){t:for(var e,n=t.length,r=1,i=-1;r0&&(i=0)}return i>0?t.slice(0,i)+t.slice(e+1):t}(t)),E&&0==+t&&(E=!1),f=(E?"("===h?h:u:"-"===h||"("===h?"":h)+f,w=("s"===m?ec[8+Gs/3]:"")+w+(E&&"("===h?")":""),k)for(i=-1,a=t.length;++i(c=t.charCodeAt(i))||c>57){w=(46===c?o+t.slice(i+1):t.slice(i))+w,t=t.slice(0,i);break}}y&&!d&&(t=r(t,1/0));var T=f.length+t.length+w.length,C=T>1)+f+t+w+C.slice(T);break;default:t=C+f+t+w}return s(t)}return g=void 0===g?6:/[gprs]/.test(m)?Math.max(1,Math.min(21,g)):Math.max(0,Math.min(20,g)),w.toString=function(){return t+""},w}return{format:h,formatPrefix:function(t,e){var n=h(((t=Hs(t)).type="f",t)),r=3*Math.max(-8,Math.min(8,Math.floor($s(e)/3))),i=Math.pow(10,-r),a=ec[8+r/3];return function(t){return n(i*t)+a}}}};function rc(t){return qs=nc(t),Xs=qs.format,Zs=qs.formatPrefix,qs}rc({decimal:".",thousands:",",grouping:[3],currency:["$",""],minus:"-"});var ic=function(t){return Math.max(0,-$s(Math.abs(t)))},ac=function(t,e){return Math.max(0,3*Math.max(-8,Math.min(8,Math.floor($s(e)/3)))-$s(Math.abs(t)))},oc=function(t,e){return t=Math.abs(t),e=Math.abs(e)-t,Math.max(0,$s(e)-$s(t))+1},sc=function(){return new cc};function cc(){this.reset()}cc.prototype={constructor:cc,reset:function(){this.s=this.t=0},add:function(t){lc(uc,t,this.t),lc(this,uc.s,this.s),this.s?this.t+=uc.t:this.s=uc.t},valueOf:function(){return this.s}};var uc=new cc;function lc(t,e,n){var r=t.s=e+n,i=r-e,a=r-i;t.t=e-a+(n-i)}var hc=Math.PI,fc=hc/2,dc=hc/4,pc=2*hc,yc=180/hc,gc=hc/180,vc=Math.abs,mc=Math.atan,bc=Math.atan2,xc=Math.cos,_c=Math.ceil,kc=Math.exp,wc=(Math.floor,Math.log),Ec=Math.pow,Tc=Math.sin,Cc=Math.sign||function(t){return t>0?1:t<0?-1:0},Sc=Math.sqrt,Ac=Math.tan;function Mc(t){return t>1?0:t<-1?hc:Math.acos(t)}function Oc(t){return t>1?fc:t<-1?-fc:Math.asin(t)}function Dc(t){return(t=Tc(t/2))*t}function Nc(){}function Bc(t,e){t&&Pc.hasOwnProperty(t.type)&&Pc[t.type](t,e)}var Lc={Feature:function(t,e){Bc(t.geometry,e)},FeatureCollection:function(t,e){for(var n=t.features,r=-1,i=n.length;++r=0?1:-1,i=r*n,a=xc(e=(e*=gc)/2+dc),o=Tc(e),s=Uc*o,c=zc*a+s*xc(i),u=s*r*Tc(i);Wc.add(bc(u,c)),Yc=t,zc=a,Uc=o}var Jc=function(t){return Hc.reset(),$c(t,Vc),2*Hc};function Kc(t){return[bc(t[1],t[0]),Oc(t[2])]}function Qc(t){var e=t[0],n=t[1],r=xc(n);return[r*xc(e),r*Tc(e),Tc(n)]}function tu(t,e){return t[0]*e[0]+t[1]*e[1]+t[2]*e[2]}function eu(t,e){return[t[1]*e[2]-t[2]*e[1],t[2]*e[0]-t[0]*e[2],t[0]*e[1]-t[1]*e[0]]}function nu(t,e){t[0]+=e[0],t[1]+=e[1],t[2]+=e[2]}function ru(t,e){return[t[0]*e,t[1]*e,t[2]*e]}function iu(t){var e=Sc(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]);t[0]/=e,t[1]/=e,t[2]/=e}var au,ou,su,cu,uu,lu,hu,fu,du,pu,yu=sc(),gu={point:vu,lineStart:bu,lineEnd:xu,polygonStart:function(){gu.point=_u,gu.lineStart=ku,gu.lineEnd=wu,yu.reset(),Vc.polygonStart()},polygonEnd:function(){Vc.polygonEnd(),gu.point=vu,gu.lineStart=bu,gu.lineEnd=xu,Wc<0?(au=-(su=180),ou=-(cu=90)):yu>1e-6?cu=90:yu<-1e-6&&(ou=-90),pu[0]=au,pu[1]=su},sphere:function(){au=-(su=180),ou=-(cu=90)}};function vu(t,e){du.push(pu=[au=t,su=t]),ecu&&(cu=e)}function mu(t,e){var n=Qc([t*gc,e*gc]);if(fu){var r=eu(fu,n),i=eu([r[1],-r[0],0],r);iu(i),i=Kc(i);var a,o=t-uu,s=o>0?1:-1,c=i[0]*yc*s,u=vc(o)>180;u^(s*uucu&&(cu=a):u^(s*uu<(c=(c+360)%360-180)&&ccu&&(cu=e)),u?tEu(au,su)&&(su=t):Eu(t,su)>Eu(au,su)&&(au=t):su>=au?(tsu&&(su=t)):t>uu?Eu(au,t)>Eu(au,su)&&(su=t):Eu(t,su)>Eu(au,su)&&(au=t)}else du.push(pu=[au=t,su=t]);ecu&&(cu=e),fu=n,uu=t}function bu(){gu.point=mu}function xu(){pu[0]=au,pu[1]=su,gu.point=vu,fu=null}function _u(t,e){if(fu){var n=t-uu;yu.add(vc(n)>180?n+(n>0?360:-360):n)}else lu=t,hu=e;Vc.point(t,e),mu(t,e)}function ku(){Vc.lineStart()}function wu(){_u(lu,hu),Vc.lineEnd(),vc(yu)>1e-6&&(au=-(su=180)),pu[0]=au,pu[1]=su,fu=null}function Eu(t,e){return(e-=t)<0?e+360:e}function Tu(t,e){return t[0]-e[0]}function Cu(t,e){return t[0]<=t[1]?t[0]<=e&&e<=t[1]:eEu(r[0],r[1])&&(r[1]=i[1]),Eu(i[0],r[1])>Eu(r[0],r[1])&&(r[0]=i[0])):a.push(r=i);for(o=-1/0,e=0,r=a[n=a.length-1];e<=n;r=i,++e)i=a[e],(s=Eu(r[1],i[0]))>o&&(o=s,au=i[0],su=r[1])}return du=pu=null,au===1/0||ou===1/0?[[NaN,NaN],[NaN,NaN]]:[[au,ou],[su,cu]]},Wu={sphere:Nc,point:Hu,lineStart:Gu,lineEnd:Zu,polygonStart:function(){Wu.lineStart=Ju,Wu.lineEnd=Ku},polygonEnd:function(){Wu.lineStart=Gu,Wu.lineEnd=Zu}};function Hu(t,e){t*=gc;var n=xc(e*=gc);Vu(n*xc(t),n*Tc(t),Tc(e))}function Vu(t,e,n){++Su,Mu+=(t-Mu)/Su,Ou+=(e-Ou)/Su,Du+=(n-Du)/Su}function Gu(){Wu.point=qu}function qu(t,e){t*=gc;var n=xc(e*=gc);Yu=n*xc(t),zu=n*Tc(t),Uu=Tc(e),Wu.point=Xu,Vu(Yu,zu,Uu)}function Xu(t,e){t*=gc;var n=xc(e*=gc),r=n*xc(t),i=n*Tc(t),a=Tc(e),o=bc(Sc((o=zu*a-Uu*i)*o+(o=Uu*r-Yu*a)*o+(o=Yu*i-zu*r)*o),Yu*r+zu*i+Uu*a);Au+=o,Nu+=o*(Yu+(Yu=r)),Bu+=o*(zu+(zu=i)),Lu+=o*(Uu+(Uu=a)),Vu(Yu,zu,Uu)}function Zu(){Wu.point=Hu}function Ju(){Wu.point=Qu}function Ku(){tl(ju,Ru),Wu.point=Hu}function Qu(t,e){ju=t,Ru=e,t*=gc,e*=gc,Wu.point=tl;var n=xc(e);Yu=n*xc(t),zu=n*Tc(t),Uu=Tc(e),Vu(Yu,zu,Uu)}function tl(t,e){t*=gc;var n=xc(e*=gc),r=n*xc(t),i=n*Tc(t),a=Tc(e),o=zu*a-Uu*i,s=Uu*r-Yu*a,c=Yu*i-zu*r,u=Sc(o*o+s*s+c*c),l=Oc(u),h=u&&-l/u;Pu+=h*o,Iu+=h*s,Fu+=h*c,Au+=l,Nu+=l*(Yu+(Yu=r)),Bu+=l*(zu+(zu=i)),Lu+=l*(Uu+(Uu=a)),Vu(Yu,zu,Uu)}var el=function(t){Su=Au=Mu=Ou=Du=Nu=Bu=Lu=Pu=Iu=Fu=0,$c(t,Wu);var e=Pu,n=Iu,r=Fu,i=e*e+n*n+r*r;return i<1e-12&&(e=Nu,n=Bu,r=Lu,Au<1e-6&&(e=Mu,n=Ou,r=Du),(i=e*e+n*n+r*r)<1e-12)?[NaN,NaN]:[bc(n,e)*yc,Oc(r/Sc(i))*yc]},nl=function(t){return function(){return t}},rl=function(t,e){function n(n,r){return n=t(n,r),e(n[0],n[1])}return t.invert&&e.invert&&(n.invert=function(n,r){return(n=e.invert(n,r))&&t.invert(n[0],n[1])}),n};function il(t,e){return[vc(t)>hc?t+Math.round(-t/pc)*pc:t,e]}function al(t,e,n){return(t%=pc)?e||n?rl(sl(t),cl(e,n)):sl(t):e||n?cl(e,n):il}function ol(t){return function(e,n){return[(e+=t)>hc?e-pc:e<-hc?e+pc:e,n]}}function sl(t){var e=ol(t);return e.invert=ol(-t),e}function cl(t,e){var n=xc(t),r=Tc(t),i=xc(e),a=Tc(e);function o(t,e){var o=xc(e),s=xc(t)*o,c=Tc(t)*o,u=Tc(e),l=u*n+s*r;return[bc(c*i-l*a,s*n-u*r),Oc(l*i+c*a)]}return o.invert=function(t,e){var o=xc(e),s=xc(t)*o,c=Tc(t)*o,u=Tc(e),l=u*i-c*a;return[bc(c*i+u*a,s*n+l*r),Oc(l*n-s*r)]},o}il.invert=il;var ul=function(t){function e(e){return(e=t(e[0]*gc,e[1]*gc))[0]*=yc,e[1]*=yc,e}return t=al(t[0]*gc,t[1]*gc,t.length>2?t[2]*gc:0),e.invert=function(e){return(e=t.invert(e[0]*gc,e[1]*gc))[0]*=yc,e[1]*=yc,e},e};function ll(t,e,n,r,i,a){if(n){var o=xc(e),s=Tc(e),c=r*n;null==i?(i=e+r*pc,a=e-c/2):(i=hl(o,i),a=hl(o,a),(r>0?ia)&&(i+=r*pc));for(var u,l=i;r>0?l>a:l1&&e.push(e.pop().concat(e.shift()))},result:function(){var n=e;return e=[],t=null,n}}},pl=function(t,e){return vc(t[0]-e[0])<1e-6&&vc(t[1]-e[1])<1e-6};function yl(t,e,n,r){this.x=t,this.z=e,this.o=n,this.e=r,this.v=!1,this.n=this.p=null}var gl=function(t,e,n,r,i){var a,o,s=[],c=[];if(t.forEach((function(t){if(!((e=t.length-1)<=0)){var e,n,r=t[0],o=t[e];if(pl(r,o)){for(i.lineStart(),a=0;a=0;--a)i.point((l=u[a])[0],l[1]);else r(f.x,f.p.x,-1,i);f=f.p}u=(f=f.o).z,d=!d}while(!f.v);i.lineEnd()}}};function vl(t){if(e=t.length){for(var e,n,r=0,i=t[0];++r=0?1:-1,T=E*w,C=T>hc,S=y*_;if(ml.add(bc(S*E*Tc(T),g*k+S*xc(T))),o+=C?w+E*pc:w,C^d>=n^b>=n){var A=eu(Qc(f),Qc(m));iu(A);var M=eu(a,A);iu(M);var O=(C^w>=0?-1:1)*Oc(M[2]);(r>O||r===O&&(A[0]||A[1]))&&(s+=C^w>=0?1:-1)}}return(o<-1e-6||o<1e-6&&ml<-1e-6)^1&s},_l=function(t,e,n,r){return function(i){var a,o,s,c=e(i),u=dl(),l=e(u),h=!1,f={point:d,lineStart:y,lineEnd:g,polygonStart:function(){f.point=v,f.lineStart=m,f.lineEnd=b,o=[],a=[]},polygonEnd:function(){f.point=d,f.lineStart=y,f.lineEnd=g,o=F(o);var t=xl(a,r);o.length?(h||(i.polygonStart(),h=!0),gl(o,wl,t,n,i)):t&&(h||(i.polygonStart(),h=!0),i.lineStart(),n(null,null,1,i),i.lineEnd()),h&&(i.polygonEnd(),h=!1),o=a=null},sphere:function(){i.polygonStart(),i.lineStart(),n(null,null,1,i),i.lineEnd(),i.polygonEnd()}};function d(e,n){t(e,n)&&i.point(e,n)}function p(t,e){c.point(t,e)}function y(){f.point=p,c.lineStart()}function g(){f.point=d,c.lineEnd()}function v(t,e){s.push([t,e]),l.point(t,e)}function m(){l.lineStart(),s=[]}function b(){v(s[0][0],s[0][1]),l.lineEnd();var t,e,n,r,c=l.clean(),f=u.result(),d=f.length;if(s.pop(),a.push(s),s=null,d)if(1&c){if((e=(n=f[0]).length-1)>0){for(h||(i.polygonStart(),h=!0),i.lineStart(),t=0;t1&&2&c&&f.push(f.pop().concat(f.shift())),o.push(f.filter(kl))}return f}};function kl(t){return t.length>1}function wl(t,e){return((t=t.x)[0]<0?t[1]-fc-1e-6:fc-t[1])-((e=e.x)[0]<0?e[1]-fc-1e-6:fc-e[1])}var El=_l((function(){return!0}),(function(t){var e,n=NaN,r=NaN,i=NaN;return{lineStart:function(){t.lineStart(),e=1},point:function(a,o){var s=a>0?hc:-hc,c=vc(a-n);vc(c-hc)<1e-6?(t.point(n,r=(r+o)/2>0?fc:-fc),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(s,r),t.point(a,r),e=0):i!==s&&c>=hc&&(vc(n-i)<1e-6&&(n-=1e-6*i),vc(a-s)<1e-6&&(a-=1e-6*s),r=function(t,e,n,r){var i,a,o=Tc(t-n);return vc(o)>1e-6?mc((Tc(e)*(a=xc(r))*Tc(n)-Tc(r)*(i=xc(e))*Tc(t))/(i*a*o)):(e+r)/2}(n,r,a,o),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(s,r),e=0),t.point(n=a,r=o),i=s},lineEnd:function(){t.lineEnd(),n=r=NaN},clean:function(){return 2-e}}}),(function(t,e,n,r){var i;if(null==t)i=n*fc,r.point(-hc,i),r.point(0,i),r.point(hc,i),r.point(hc,0),r.point(hc,-i),r.point(0,-i),r.point(-hc,-i),r.point(-hc,0),r.point(-hc,i);else if(vc(t[0]-e[0])>1e-6){var a=t[0]0,i=vc(e)>1e-6;function a(t,n){return xc(t)*xc(n)>e}function o(t,n,r){var i=[1,0,0],a=eu(Qc(t),Qc(n)),o=tu(a,a),s=a[0],c=o-s*s;if(!c)return!r&&t;var u=e*o/c,l=-e*s/c,h=eu(i,a),f=ru(i,u);nu(f,ru(a,l));var d=h,p=tu(f,d),y=tu(d,d),g=p*p-y*(tu(f,f)-1);if(!(g<0)){var v=Sc(g),m=ru(d,(-p-v)/y);if(nu(m,f),m=Kc(m),!r)return m;var b,x=t[0],_=n[0],k=t[1],w=n[1];_0^m[1]<(vc(m[0]-x)<1e-6?k:w):k<=m[1]&&m[1]<=w:E>hc^(x<=m[0]&&m[0]<=_)){var C=ru(d,(-p+v)/y);return nu(C,f),[m,Kc(C)]}}}function s(e,n){var i=r?t:hc-t,a=0;return e<-i?a|=1:e>i&&(a|=2),n<-i?a|=4:n>i&&(a|=8),a}return _l(a,(function(t){var e,n,c,u,l;return{lineStart:function(){u=c=!1,l=1},point:function(h,f){var d,p=[h,f],y=a(h,f),g=r?y?0:s(h,f):y?s(h+(h<0?hc:-hc),f):0;if(!e&&(u=c=y)&&t.lineStart(),y!==c&&(!(d=o(e,p))||pl(e,d)||pl(p,d))&&(p[0]+=1e-6,p[1]+=1e-6,y=a(p[0],p[1])),y!==c)l=0,y?(t.lineStart(),d=o(p,e),t.point(d[0],d[1])):(d=o(e,p),t.point(d[0],d[1]),t.lineEnd()),e=d;else if(i&&e&&r^y){var v;g&n||!(v=o(p,e,!0))||(l=0,r?(t.lineStart(),t.point(v[0][0],v[0][1]),t.point(v[1][0],v[1][1]),t.lineEnd()):(t.point(v[1][0],v[1][1]),t.lineEnd(),t.lineStart(),t.point(v[0][0],v[0][1])))}!y||e&&pl(e,p)||t.point(p[0],p[1]),e=p,c=y,n=g},lineEnd:function(){c&&t.lineEnd(),e=null},clean:function(){return l|(u&&c)<<1}}}),(function(e,r,i,a){ll(a,t,n,i,e,r)}),r?[0,-t]:[-hc,t-hc])};function Cl(t,e,n,r){function i(i,a){return t<=i&&i<=n&&e<=a&&a<=r}function a(i,a,s,u){var l=0,h=0;if(null==i||(l=o(i,s))!==(h=o(a,s))||c(i,a)<0^s>0)do{u.point(0===l||3===l?t:n,l>1?r:e)}while((l=(l+s+4)%4)!==h);else u.point(a[0],a[1])}function o(r,i){return vc(r[0]-t)<1e-6?i>0?0:3:vc(r[0]-n)<1e-6?i>0?2:1:vc(r[1]-e)<1e-6?i>0?1:0:i>0?3:2}function s(t,e){return c(t.x,e.x)}function c(t,e){var n=o(t,1),r=o(e,1);return n!==r?n-r:0===n?e[1]-t[1]:1===n?t[0]-e[0]:2===n?t[1]-e[1]:e[0]-t[0]}return function(o){var c,u,l,h,f,d,p,y,g,v,m,b=o,x=dl(),_={point:k,lineStart:function(){_.point=w,u&&u.push(l=[]);v=!0,g=!1,p=y=NaN},lineEnd:function(){c&&(w(h,f),d&&g&&x.rejoin(),c.push(x.result()));_.point=k,g&&b.lineEnd()},polygonStart:function(){b=x,c=[],u=[],m=!0},polygonEnd:function(){var e=function(){for(var e=0,n=0,i=u.length;nr&&(f-a)*(r-o)>(d-o)*(t-a)&&++e:d<=r&&(f-a)*(r-o)<(d-o)*(t-a)&&--e;return e}(),n=m&&e,i=(c=F(c)).length;(n||i)&&(o.polygonStart(),n&&(o.lineStart(),a(null,null,1,o),o.lineEnd()),i&&gl(c,s,e,a,o),o.polygonEnd());b=o,c=u=l=null}};function k(t,e){i(t,e)&&b.point(t,e)}function w(a,o){var s=i(a,o);if(u&&l.push([a,o]),v)h=a,f=o,d=s,v=!1,s&&(b.lineStart(),b.point(a,o));else if(s&&g)b.point(a,o);else{var c=[p=Math.max(-1e9,Math.min(1e9,p)),y=Math.max(-1e9,Math.min(1e9,y))],x=[a=Math.max(-1e9,Math.min(1e9,a)),o=Math.max(-1e9,Math.min(1e9,o))];!function(t,e,n,r,i,a){var o,s=t[0],c=t[1],u=0,l=1,h=e[0]-s,f=e[1]-c;if(o=n-s,h||!(o>0)){if(o/=h,h<0){if(o0){if(o>l)return;o>u&&(u=o)}if(o=i-s,h||!(o<0)){if(o/=h,h<0){if(o>l)return;o>u&&(u=o)}else if(h>0){if(o0)){if(o/=f,f<0){if(o0){if(o>l)return;o>u&&(u=o)}if(o=a-c,f||!(o<0)){if(o/=f,f<0){if(o>l)return;o>u&&(u=o)}else if(f>0){if(o0&&(t[0]=s+u*h,t[1]=c+u*f),l<1&&(e[0]=s+l*h,e[1]=c+l*f),!0}}}}}(c,x,t,e,n,r)?s&&(b.lineStart(),b.point(a,o),m=!1):(g||(b.lineStart(),b.point(c[0],c[1])),b.point(x[0],x[1]),s||b.lineEnd(),m=!1)}p=a,y=o,g=s}return _}}var Sl,Al,Ml,Ol=function(){var t,e,n,r=0,i=0,a=960,o=500;return n={stream:function(n){return t&&e===n?t:t=Cl(r,i,a,o)(e=n)},extent:function(s){return arguments.length?(r=+s[0][0],i=+s[0][1],a=+s[1][0],o=+s[1][1],t=e=null,n):[[r,i],[a,o]]}}},Dl=sc(),Nl={sphere:Nc,point:Nc,lineStart:function(){Nl.point=Ll,Nl.lineEnd=Bl},lineEnd:Nc,polygonStart:Nc,polygonEnd:Nc};function Bl(){Nl.point=Nl.lineEnd=Nc}function Ll(t,e){Sl=t*=gc,Al=Tc(e*=gc),Ml=xc(e),Nl.point=Pl}function Pl(t,e){t*=gc;var n=Tc(e*=gc),r=xc(e),i=vc(t-Sl),a=xc(i),o=r*Tc(i),s=Ml*n-Al*r*a,c=Al*n+Ml*r*a;Dl.add(bc(Sc(o*o+s*s),c)),Sl=t,Al=n,Ml=r}var Il=function(t){return Dl.reset(),$c(t,Nl),+Dl},Fl=[null,null],jl={type:"LineString",coordinates:Fl},Rl=function(t,e){return Fl[0]=t,Fl[1]=e,Il(jl)},Yl={Feature:function(t,e){return Ul(t.geometry,e)},FeatureCollection:function(t,e){for(var n=t.features,r=-1,i=n.length;++r0&&(i=Rl(t[a],t[a-1]))>0&&n<=i&&r<=i&&(n+r-i)*(1-Math.pow((n-r)/i,2))<1e-12*i)return!0;n=r}return!1}function Hl(t,e){return!!xl(t.map(Vl),Gl(e))}function Vl(t){return(t=t.map(Gl)).pop(),t}function Gl(t){return[t[0]*gc,t[1]*gc]}var ql=function(t,e){return(t&&Yl.hasOwnProperty(t.type)?Yl[t.type]:Ul)(t,e)};function Xl(t,e,n){var r=k(t,e-1e-6,n).concat(e);return function(t){return r.map((function(e){return[t,e]}))}}function Zl(t,e,n){var r=k(t,e-1e-6,n).concat(e);return function(t){return r.map((function(e){return[e,t]}))}}function Jl(){var t,e,n,r,i,a,o,s,c,u,l,h,f=10,d=f,p=90,y=360,g=2.5;function v(){return{type:"MultiLineString",coordinates:m()}}function m(){return k(_c(r/p)*p,n,p).map(l).concat(k(_c(s/y)*y,o,y).map(h)).concat(k(_c(e/f)*f,t,f).filter((function(t){return vc(t%p)>1e-6})).map(c)).concat(k(_c(a/d)*d,i,d).filter((function(t){return vc(t%y)>1e-6})).map(u))}return v.lines=function(){return m().map((function(t){return{type:"LineString",coordinates:t}}))},v.outline=function(){return{type:"Polygon",coordinates:[l(r).concat(h(o).slice(1),l(n).reverse().slice(1),h(s).reverse().slice(1))]}},v.extent=function(t){return arguments.length?v.extentMajor(t).extentMinor(t):v.extentMinor()},v.extentMajor=function(t){return arguments.length?(r=+t[0][0],n=+t[1][0],s=+t[0][1],o=+t[1][1],r>n&&(t=r,r=n,n=t),s>o&&(t=s,s=o,o=t),v.precision(g)):[[r,s],[n,o]]},v.extentMinor=function(n){return arguments.length?(e=+n[0][0],t=+n[1][0],a=+n[0][1],i=+n[1][1],e>t&&(n=e,e=t,t=n),a>i&&(n=a,a=i,i=n),v.precision(g)):[[e,a],[t,i]]},v.step=function(t){return arguments.length?v.stepMajor(t).stepMinor(t):v.stepMinor()},v.stepMajor=function(t){return arguments.length?(p=+t[0],y=+t[1],v):[p,y]},v.stepMinor=function(t){return arguments.length?(f=+t[0],d=+t[1],v):[f,d]},v.precision=function(f){return arguments.length?(g=+f,c=Xl(a,i,90),u=Zl(e,t,g),l=Xl(s,o,90),h=Zl(r,n,g),v):g},v.extentMajor([[-180,1e-6-90],[180,90-1e-6]]).extentMinor([[-180,-80-1e-6],[180,80+1e-6]])}function Kl(){return Jl()()}var Ql,th,eh,nh,rh=function(t,e){var n=t[0]*gc,r=t[1]*gc,i=e[0]*gc,a=e[1]*gc,o=xc(r),s=Tc(r),c=xc(a),u=Tc(a),l=o*xc(n),h=o*Tc(n),f=c*xc(i),d=c*Tc(i),p=2*Oc(Sc(Dc(a-r)+o*c*Dc(i-n))),y=Tc(p),g=p?function(t){var e=Tc(t*=p)/y,n=Tc(p-t)/y,r=n*l+e*f,i=n*h+e*d,a=n*s+e*u;return[bc(i,r)*yc,bc(a,Sc(r*r+i*i))*yc]}:function(){return[n*yc,r*yc]};return g.distance=p,g},ih=function(t){return t},ah=sc(),oh=sc(),sh={point:Nc,lineStart:Nc,lineEnd:Nc,polygonStart:function(){sh.lineStart=ch,sh.lineEnd=hh},polygonEnd:function(){sh.lineStart=sh.lineEnd=sh.point=Nc,ah.add(vc(oh)),oh.reset()},result:function(){var t=ah/2;return ah.reset(),t}};function ch(){sh.point=uh}function uh(t,e){sh.point=lh,Ql=eh=t,th=nh=e}function lh(t,e){oh.add(nh*t-eh*e),eh=t,nh=e}function hh(){lh(Ql,th)}var fh=sh,dh=1/0,ph=dh,yh=-dh,gh=yh;var vh,mh,bh,xh,_h={point:function(t,e){tyh&&(yh=t);egh&&(gh=e)},lineStart:Nc,lineEnd:Nc,polygonStart:Nc,polygonEnd:Nc,result:function(){var t=[[dh,ph],[yh,gh]];return yh=gh=-(ph=dh=1/0),t}},kh=0,wh=0,Eh=0,Th=0,Ch=0,Sh=0,Ah=0,Mh=0,Oh=0,Dh={point:Nh,lineStart:Bh,lineEnd:Ih,polygonStart:function(){Dh.lineStart=Fh,Dh.lineEnd=jh},polygonEnd:function(){Dh.point=Nh,Dh.lineStart=Bh,Dh.lineEnd=Ih},result:function(){var t=Oh?[Ah/Oh,Mh/Oh]:Sh?[Th/Sh,Ch/Sh]:Eh?[kh/Eh,wh/Eh]:[NaN,NaN];return kh=wh=Eh=Th=Ch=Sh=Ah=Mh=Oh=0,t}};function Nh(t,e){kh+=t,wh+=e,++Eh}function Bh(){Dh.point=Lh}function Lh(t,e){Dh.point=Ph,Nh(bh=t,xh=e)}function Ph(t,e){var n=t-bh,r=e-xh,i=Sc(n*n+r*r);Th+=i*(bh+t)/2,Ch+=i*(xh+e)/2,Sh+=i,Nh(bh=t,xh=e)}function Ih(){Dh.point=Nh}function Fh(){Dh.point=Rh}function jh(){Yh(vh,mh)}function Rh(t,e){Dh.point=Yh,Nh(vh=bh=t,mh=xh=e)}function Yh(t,e){var n=t-bh,r=e-xh,i=Sc(n*n+r*r);Th+=i*(bh+t)/2,Ch+=i*(xh+e)/2,Sh+=i,Ah+=(i=xh*t-bh*e)*(bh+t),Mh+=i*(xh+e),Oh+=3*i,Nh(bh=t,xh=e)}var zh=Dh;function Uh(t){this._context=t}Uh.prototype={_radius:4.5,pointRadius:function(t){return this._radius=t,this},polygonStart:function(){this._line=0},polygonEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){0===this._line&&this._context.closePath(),this._point=NaN},point:function(t,e){switch(this._point){case 0:this._context.moveTo(t,e),this._point=1;break;case 1:this._context.lineTo(t,e);break;default:this._context.moveTo(t+this._radius,e),this._context.arc(t,e,this._radius,0,pc)}},result:Nc};var $h,Wh,Hh,Vh,Gh,qh=sc(),Xh={point:Nc,lineStart:function(){Xh.point=Zh},lineEnd:function(){$h&&Jh(Wh,Hh),Xh.point=Nc},polygonStart:function(){$h=!0},polygonEnd:function(){$h=null},result:function(){var t=+qh;return qh.reset(),t}};function Zh(t,e){Xh.point=Jh,Wh=Vh=t,Hh=Gh=e}function Jh(t,e){Vh-=t,Gh-=e,qh.add(Sc(Vh*Vh+Gh*Gh)),Vh=t,Gh=e}var Kh=Xh;function Qh(){this._string=[]}function tf(t){return"m0,"+t+"a"+t+","+t+" 0 1,1 0,"+-2*t+"a"+t+","+t+" 0 1,1 0,"+2*t+"z"}Qh.prototype={_radius:4.5,_circle:tf(4.5),pointRadius:function(t){return(t=+t)!==this._radius&&(this._radius=t,this._circle=null),this},polygonStart:function(){this._line=0},polygonEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){0===this._line&&this._string.push("Z"),this._point=NaN},point:function(t,e){switch(this._point){case 0:this._string.push("M",t,",",e),this._point=1;break;case 1:this._string.push("L",t,",",e);break;default:null==this._circle&&(this._circle=tf(this._radius)),this._string.push("M",t,",",e,this._circle)}},result:function(){if(this._string.length){var t=this._string.join("");return this._string=[],t}return null}};var ef=function(t,e){var n,r,i=4.5;function a(t){return t&&("function"==typeof i&&r.pointRadius(+i.apply(this,arguments)),$c(t,n(r))),r.result()}return a.area=function(t){return $c(t,n(fh)),fh.result()},a.measure=function(t){return $c(t,n(Kh)),Kh.result()},a.bounds=function(t){return $c(t,n(_h)),_h.result()},a.centroid=function(t){return $c(t,n(zh)),zh.result()},a.projection=function(e){return arguments.length?(n=null==e?(t=null,ih):(t=e).stream,a):t},a.context=function(t){return arguments.length?(r=null==t?(e=null,new Qh):new Uh(e=t),"function"!=typeof i&&r.pointRadius(i),a):e},a.pointRadius=function(t){return arguments.length?(i="function"==typeof t?t:(r.pointRadius(+t),+t),a):i},a.projection(t).context(e)},nf=function(t){return{stream:rf(t)}};function rf(t){return function(e){var n=new af;for(var r in t)n[r]=t[r];return n.stream=e,n}}function af(){}function of(t,e,n){var r=t.clipExtent&&t.clipExtent();return t.scale(150).translate([0,0]),null!=r&&t.clipExtent(null),$c(n,t.stream(_h)),e(_h.result()),null!=r&&t.clipExtent(r),t}function sf(t,e,n){return of(t,(function(n){var r=e[1][0]-e[0][0],i=e[1][1]-e[0][1],a=Math.min(r/(n[1][0]-n[0][0]),i/(n[1][1]-n[0][1])),o=+e[0][0]+(r-a*(n[1][0]+n[0][0]))/2,s=+e[0][1]+(i-a*(n[1][1]+n[0][1]))/2;t.scale(150*a).translate([o,s])}),n)}function cf(t,e,n){return sf(t,[[0,0],e],n)}function uf(t,e,n){return of(t,(function(n){var r=+e,i=r/(n[1][0]-n[0][0]),a=(r-i*(n[1][0]+n[0][0]))/2,o=-i*n[0][1];t.scale(150*i).translate([a,o])}),n)}function lf(t,e,n){return of(t,(function(n){var r=+e,i=r/(n[1][1]-n[0][1]),a=-i*n[0][0],o=(r-i*(n[1][1]+n[0][1]))/2;t.scale(150*i).translate([a,o])}),n)}af.prototype={constructor:af,point:function(t,e){this.stream.point(t,e)},sphere:function(){this.stream.sphere()},lineStart:function(){this.stream.lineStart()},lineEnd:function(){this.stream.lineEnd()},polygonStart:function(){this.stream.polygonStart()},polygonEnd:function(){this.stream.polygonEnd()}};var hf=xc(30*gc),ff=function(t,e){return+e?function(t,e){function n(r,i,a,o,s,c,u,l,h,f,d,p,y,g){var v=u-r,m=l-i,b=v*v+m*m;if(b>4*e&&y--){var x=o+f,_=s+d,k=c+p,w=Sc(x*x+_*_+k*k),E=Oc(k/=w),T=vc(vc(k)-1)<1e-6||vc(a-h)<1e-6?(a+h)/2:bc(_,x),C=t(T,E),S=C[0],A=C[1],M=S-r,O=A-i,D=m*M-v*O;(D*D/b>e||vc((v*M+m*O)/b-.5)>.3||o*f+s*d+c*p2?t[2]%360*gc:0,S()):[g*yc,v*yc,m*yc]},T.angle=function(t){return arguments.length?(b=t%360*gc,S()):b*yc},T.precision=function(t){return arguments.length?(o=ff(s,E=t*t),A()):Sc(E)},T.fitExtent=function(t,e){return sf(T,t,e)},T.fitSize=function(t,e){return cf(T,t,e)},T.fitWidth=function(t,e){return uf(T,t,e)},T.fitHeight=function(t,e){return lf(T,t,e)},function(){return e=t.apply(this,arguments),T.invert=e.invert&&C,S()}}function mf(t){var e=0,n=hc/3,r=vf(t),i=r(e,n);return i.parallels=function(t){return arguments.length?r(e=t[0]*gc,n=t[1]*gc):[e*yc,n*yc]},i}function bf(t,e){var n=Tc(t),r=(n+Tc(e))/2;if(vc(r)<1e-6)return function(t){var e=xc(t);function n(t,n){return[t*e,Tc(n)/e]}return n.invert=function(t,n){return[t/e,Oc(n*e)]},n}(t);var i=1+n*(2*r-n),a=Sc(i)/r;function o(t,e){var n=Sc(i-2*r*Tc(e))/r;return[n*Tc(t*=r),a-n*xc(t)]}return o.invert=function(t,e){var n=a-e;return[bc(t,vc(n))/r*Cc(n),Oc((i-(t*t+n*n)*r*r)/(2*r))]},o}var xf=function(){return mf(bf).scale(155.424).center([0,33.6442])},_f=function(){return xf().parallels([29.5,45.5]).scale(1070).translate([480,250]).rotate([96,0]).center([-.6,38.7])};var kf=function(){var t,e,n,r,i,a,o=_f(),s=xf().rotate([154,0]).center([-2,58.5]).parallels([55,65]),c=xf().rotate([157,0]).center([-3,19.9]).parallels([8,18]),u={point:function(t,e){a=[t,e]}};function l(t){var e=t[0],o=t[1];return a=null,n.point(e,o),a||(r.point(e,o),a)||(i.point(e,o),a)}function h(){return t=e=null,l}return l.invert=function(t){var e=o.scale(),n=o.translate(),r=(t[0]-n[0])/e,i=(t[1]-n[1])/e;return(i>=.12&&i<.234&&r>=-.425&&r<-.214?s:i>=.166&&i<.234&&r>=-.214&&r<-.115?c:o).invert(t)},l.stream=function(n){return t&&e===n?t:(r=[o.stream(e=n),s.stream(n),c.stream(n)],i=r.length,t={point:function(t,e){for(var n=-1;++n0?e<1e-6-fc&&(e=1e-6-fc):e>fc-1e-6&&(e=fc-1e-6);var n=i/Ec(Nf(e),r);return[n*Tc(r*t),i-n*xc(r*t)]}return a.invert=function(t,e){var n=i-e,a=Cc(r)*Sc(t*t+n*n);return[bc(t,vc(n))/r*Cc(n),2*mc(Ec(i/a,1/r))-fc]},a}var Lf=function(){return mf(Bf).scale(109.5).parallels([30,30])};function Pf(t,e){return[t,e]}Pf.invert=Pf;var If=function(){return gf(Pf).scale(152.63)};function Ff(t,e){var n=xc(t),r=t===e?Tc(t):(n-xc(e))/(e-t),i=n/r+t;if(vc(r)<1e-6)return Pf;function a(t,e){var n=i-e,a=r*t;return[n*Tc(a),i-n*xc(a)]}return a.invert=function(t,e){var n=i-e;return[bc(t,vc(n))/r*Cc(n),i-Cc(r)*Sc(t*t+n*n)]},a}var jf=function(){return mf(Ff).scale(131.154).center([0,13.9389])},Rf=1.340264,Yf=-.081106,zf=893e-6,Uf=.003796,$f=Sc(3)/2;function Wf(t,e){var n=Oc($f*Tc(e)),r=n*n,i=r*r*r;return[t*xc(n)/($f*(Rf+3*Yf*r+i*(7*zf+9*Uf*r))),n*(Rf+Yf*r+i*(zf+Uf*r))]}Wf.invert=function(t,e){for(var n,r=e,i=r*r,a=i*i*i,o=0;o<12&&(a=(i=(r-=n=(r*(Rf+Yf*i+a*(zf+Uf*i))-e)/(Rf+3*Yf*i+a*(7*zf+9*Uf*i)))*r)*i*i,!(vc(n)<1e-12));++o);return[$f*t*(Rf+3*Yf*i+a*(7*zf+9*Uf*i))/xc(r),Oc(Tc(r)/$f)]};var Hf=function(){return gf(Wf).scale(177.158)};function Vf(t,e){var n=xc(e),r=xc(t)*n;return[n*Tc(t)/r,Tc(e)/r]}Vf.invert=Ef(mc);var Gf=function(){return gf(Vf).scale(144.049).clipAngle(60)};function qf(t,e,n,r){return 1===t&&1===e&&0===n&&0===r?ih:rf({point:function(i,a){this.stream.point(i*t+n,a*e+r)}})}var Xf=function(){var t,e,n,r,i,a,o=1,s=0,c=0,u=1,l=1,h=ih,f=null,d=ih;function p(){return r=i=null,a}return a={stream:function(t){return r&&i===t?r:r=h(d(i=t))},postclip:function(r){return arguments.length?(d=r,f=t=e=n=null,p()):d},clipExtent:function(r){return arguments.length?(d=null==r?(f=t=e=n=null,ih):Cl(f=+r[0][0],t=+r[0][1],e=+r[1][0],n=+r[1][1]),p()):null==f?null:[[f,t],[e,n]]},scale:function(t){return arguments.length?(h=qf((o=+t)*u,o*l,s,c),p()):o},translate:function(t){return arguments.length?(h=qf(o*u,o*l,s=+t[0],c=+t[1]),p()):[s,c]},reflectX:function(t){return arguments.length?(h=qf(o*(u=t?-1:1),o*l,s,c),p()):u<0},reflectY:function(t){return arguments.length?(h=qf(o*u,o*(l=t?-1:1),s,c),p()):l<0},fitExtent:function(t,e){return sf(a,t,e)},fitSize:function(t,e){return cf(a,t,e)},fitWidth:function(t,e){return uf(a,t,e)},fitHeight:function(t,e){return lf(a,t,e)}}};function Zf(t,e){var n=e*e,r=n*n;return[t*(.8707-.131979*n+r*(r*(.003971*n-.001529*r)-.013791)),e*(1.007226+n*(.015085+r*(.028874*n-.044475-.005916*r)))]}Zf.invert=function(t,e){var n,r=e,i=25;do{var a=r*r,o=a*a;r-=n=(r*(1.007226+a*(.015085+o*(.028874*a-.044475-.005916*o)))-e)/(1.007226+a*(.045255+o*(.259866*a-.311325-.005916*11*o)))}while(vc(n)>1e-6&&--i>0);return[t/(.8707+(a=r*r)*(a*(a*a*a*(.003971-.001529*a)-.013791)-.131979)),r]};var Jf=function(){return gf(Zf).scale(175.295)};function Kf(t,e){return[xc(e)*Tc(t),Tc(e)]}Kf.invert=Ef(Oc);var Qf=function(){return gf(Kf).scale(249.5).clipAngle(90+1e-6)};function td(t,e){var n=xc(e),r=1+xc(t)*n;return[n*Tc(t)/r,Tc(e)/r]}td.invert=Ef((function(t){return 2*mc(t)}));var ed=function(){return gf(td).scale(250).clipAngle(142)};function nd(t,e){return[wc(Ac((fc+e)/2)),-t]}nd.invert=function(t,e){return[-e,2*mc(kc(t))-fc]};var rd=function(){var t=Df(nd),e=t.center,n=t.rotate;return t.center=function(t){return arguments.length?e([-t[1],t[0]]):[(t=e())[1],-t[0]]},t.rotate=function(t){return arguments.length?n([t[0],t[1],t.length>2?t[2]+90:90]):[(t=n())[0],t[1],t[2]-90]},n([0,0,90]).scale(159.155)};function id(t,e){return t.parent===e.parent?1:2}function ad(t,e){return t+e.x}function od(t,e){return Math.max(t,e.y)}var sd=function(){var t=id,e=1,n=1,r=!1;function i(i){var a,o=0;i.eachAfter((function(e){var n=e.children;n?(e.x=function(t){return t.reduce(ad,0)/t.length}(n),e.y=function(t){return 1+t.reduce(od,0)}(n)):(e.x=a?o+=t(e,a):0,e.y=0,a=e)}));var s=function(t){for(var e;e=t.children;)t=e[0];return t}(i),c=function(t){for(var e;e=t.children;)t=e[e.length-1];return t}(i),u=s.x-t(s,c)/2,l=c.x+t(c,s)/2;return i.eachAfter(r?function(t){t.x=(t.x-i.x)*e,t.y=(i.y-t.y)*n}:function(t){t.x=(t.x-u)/(l-u)*e,t.y=(1-(i.y?t.y/i.y:1))*n})}return i.separation=function(e){return arguments.length?(t=e,i):t},i.size=function(t){return arguments.length?(r=!1,e=+t[0],n=+t[1],i):r?null:[e,n]},i.nodeSize=function(t){return arguments.length?(r=!0,e=+t[0],n=+t[1],i):r?[e,n]:null},i};function cd(t){var e=0,n=t.children,r=n&&n.length;if(r)for(;--r>=0;)e+=n[r].value;else e=1;t.value=e}function ud(t,e){var n,r,i,a,o,s=new dd(t),c=+t.value&&(s.value=t.value),u=[s];for(null==e&&(e=ld);n=u.pop();)if(c&&(n.value=+n.data.value),(i=e(n.data))&&(o=i.length))for(n.children=new Array(o),a=o-1;a>=0;--a)u.push(r=n.children[a]=new dd(i[a])),r.parent=n,r.depth=n.depth+1;return s.eachBefore(fd)}function ld(t){return t.children}function hd(t){t.data=t.data.data}function fd(t){var e=0;do{t.height=e}while((t=t.parent)&&t.height<++e)}function dd(t){this.data=t,this.depth=this.height=0,this.parent=null}dd.prototype=ud.prototype={constructor:dd,count:function(){return this.eachAfter(cd)},each:function(t){var e,n,r,i,a=this,o=[a];do{for(e=o.reverse(),o=[];a=e.pop();)if(t(a),n=a.children)for(r=0,i=n.length;r=0;--n)i.push(e[n]);return this},sum:function(t){return this.eachAfter((function(e){for(var n=+t(e.data)||0,r=e.children,i=r&&r.length;--i>=0;)n+=r[i].value;e.value=n}))},sort:function(t){return this.eachBefore((function(e){e.children&&e.children.sort(t)}))},path:function(t){for(var e=this,n=function(t,e){if(t===e)return t;var n=t.ancestors(),r=e.ancestors(),i=null;t=n.pop(),e=r.pop();for(;t===e;)i=t,t=n.pop(),e=r.pop();return i}(e,t),r=[e];e!==n;)e=e.parent,r.push(e);for(var i=r.length;t!==n;)r.splice(i,0,t),t=t.parent;return r},ancestors:function(){for(var t=this,e=[t];t=t.parent;)e.push(t);return e},descendants:function(){var t=[];return this.each((function(e){t.push(e)})),t},leaves:function(){var t=[];return this.eachBefore((function(e){e.children||t.push(e)})),t},links:function(){var t=this,e=[];return t.each((function(n){n!==t&&e.push({source:n.parent,target:n})})),e},copy:function(){return ud(this).eachBefore(hd)}};var pd=Array.prototype.slice;var yd=function(t){for(var e,n,r=0,i=(t=function(t){for(var e,n,r=t.length;r;)n=Math.random()*r--|0,e=t[r],t[r]=t[n],t[n]=e;return t}(pd.call(t))).length,a=[];r0&&n*n>r*r+i*i}function bd(t,e){for(var n=0;n(o*=o)?(r=(u+o-i)/(2*u),a=Math.sqrt(Math.max(0,o/u-r*r)),n.x=t.x-r*s-a*c,n.y=t.y-r*c+a*s):(r=(u+i-o)/(2*u),a=Math.sqrt(Math.max(0,i/u-r*r)),n.x=e.x+r*s-a*c,n.y=e.y+r*c+a*s)):(n.x=e.x+n.r,n.y=e.y)}function Ed(t,e){var n=t.r+e.r-1e-6,r=e.x-t.x,i=e.y-t.y;return n>0&&n*n>r*r+i*i}function Td(t){var e=t._,n=t.next._,r=e.r+n.r,i=(e.x*n.r+n.x*e.r)/r,a=(e.y*n.r+n.y*e.r)/r;return i*i+a*a}function Cd(t){this._=t,this.next=null,this.previous=null}function Sd(t){if(!(i=t.length))return 0;var e,n,r,i,a,o,s,c,u,l,h;if((e=t[0]).x=0,e.y=0,!(i>1))return e.r;if(n=t[1],e.x=-n.r,n.x=e.r,n.y=0,!(i>2))return e.r+n.r;wd(n,e,r=t[2]),e=new Cd(e),n=new Cd(n),r=new Cd(r),e.next=r.previous=n,n.next=e.previous=r,r.next=n.previous=e;t:for(s=3;s0)throw new Error("cycle");return a}return n.id=function(e){return arguments.length?(t=Od(e),n):t},n.parentId=function(t){return arguments.length?(e=Od(t),n):e},n};function Vd(t,e){return t.parent===e.parent?1:2}function Gd(t){var e=t.children;return e?e[0]:t.t}function qd(t){var e=t.children;return e?e[e.length-1]:t.t}function Xd(t,e,n){var r=n/(e.i-t.i);e.c-=r,e.s+=n,t.c+=r,e.z+=n,e.m+=n}function Zd(t,e,n){return t.a.parent===e.parent?t.a:n}function Jd(t,e){this._=t,this.parent=null,this.children=null,this.A=null,this.a=this,this.z=0,this.m=0,this.c=0,this.s=0,this.t=null,this.i=e}Jd.prototype=Object.create(dd.prototype);var Kd=function(){var t=Vd,e=1,n=1,r=null;function i(i){var c=function(t){for(var e,n,r,i,a,o=new Jd(t,0),s=[o];e=s.pop();)if(r=e._.children)for(e.children=new Array(a=r.length),i=a-1;i>=0;--i)s.push(n=e.children[i]=new Jd(r[i],i)),n.parent=e;return(o.parent=new Jd(null,0)).children=[o],o}(i);if(c.eachAfter(a),c.parent.m=-c.z,c.eachBefore(o),r)i.eachBefore(s);else{var u=i,l=i,h=i;i.eachBefore((function(t){t.xl.x&&(l=t),t.depth>h.depth&&(h=t)}));var f=u===l?1:t(u,l)/2,d=f-u.x,p=e/(l.x+f+d),y=n/(h.depth||1);i.eachBefore((function(t){t.x=(t.x+d)*p,t.y=t.depth*y}))}return i}function a(e){var n=e.children,r=e.parent.children,i=e.i?r[e.i-1]:null;if(n){!function(t){for(var e,n=0,r=0,i=t.children,a=i.length;--a>=0;)(e=i[a]).z+=n,e.m+=n,n+=e.s+(r+=e.c)}(e);var a=(n[0].z+n[n.length-1].z)/2;i?(e.z=i.z+t(e._,i._),e.m=e.z-a):e.z=a}else i&&(e.z=i.z+t(e._,i._));e.parent.A=function(e,n,r){if(n){for(var i,a=e,o=e,s=n,c=a.parent.children[0],u=a.m,l=o.m,h=s.m,f=c.m;s=qd(s),a=Gd(a),s&&a;)c=Gd(c),(o=qd(o)).a=e,(i=s.z+h-a.z-u+t(s._,a._))>0&&(Xd(Zd(s,e,r),e,i),u+=i,l+=i),h+=s.m,u+=a.m,f+=c.m,l+=o.m;s&&!qd(o)&&(o.t=s,o.m+=h-l),a&&!Gd(c)&&(c.t=a,c.m+=u-f,r=e)}return r}(e,i,e.parent.A||r[0])}function o(t){t._.x=t.z+t.parent.m,t.m+=t.parent.m}function s(t){t.x*=e,t.y=t.depth*n}return i.separation=function(e){return arguments.length?(t=e,i):t},i.size=function(t){return arguments.length?(r=!1,e=+t[0],n=+t[1],i):r?null:[e,n]},i.nodeSize=function(t){return arguments.length?(r=!0,e=+t[0],n=+t[1],i):r?[e,n]:null},i},Qd=function(t,e,n,r,i){for(var a,o=t.children,s=-1,c=o.length,u=t.value&&(i-n)/t.value;++sf&&(f=s),g=l*l*y,(d=Math.max(f/g,g/h))>p){l-=s;break}p=d}v.push(o={value:l,dice:c1?e:1)},n}(tp),rp=function(){var t=np,e=!1,n=1,r=1,i=[0],a=Dd,o=Dd,s=Dd,c=Dd,u=Dd;function l(t){return t.x0=t.y0=0,t.x1=n,t.y1=r,t.eachBefore(h),i=[0],e&&t.eachBefore(jd),t}function h(e){var n=i[e.depth],r=e.x0+n,l=e.y0+n,h=e.x1-n,f=e.y1-n;h=n-1){var l=s[e];return l.x0=i,l.y0=a,l.x1=o,void(l.y1=c)}var h=u[e],f=r/2+h,d=e+1,p=n-1;for(;d>>1;u[y]c-a){var m=(i*v+o*g)/r;t(e,d,g,i,a,m,c),t(d,n,v,m,a,o,c)}else{var b=(a*v+c*g)/r;t(e,d,g,i,a,o,b),t(d,n,v,i,b,o,c)}}(0,c,t.value,e,n,r,i)},ap=function(t,e,n,r,i){(1&t.depth?Qd:Rd)(t,e,n,r,i)},op=function t(e){function n(t,n,r,i,a){if((o=t._squarify)&&o.ratio===e)for(var o,s,c,u,l,h=-1,f=o.length,d=t.value;++h1?e:1)},n}(tp),sp=function(t){var e=t.length;return function(n){return t[Math.max(0,Math.min(e-1,Math.floor(n*e)))]}},cp=function(t,e){var n=un(+t,+e);return function(t){var e=n(t);return e-360*Math.floor(e/360)}},up=function(t,e){return t=+t,e=+e,function(n){return Math.round(t*(1-n)+e*n)}},lp=Math.SQRT2;function hp(t){return((t=Math.exp(t))+1/t)/2}var fp=function(t,e){var n,r,i=t[0],a=t[1],o=t[2],s=e[0],c=e[1],u=e[2],l=s-i,h=c-a,f=l*l+h*h;if(f<1e-12)r=Math.log(u/o)/lp,n=function(t){return[i+t*l,a+t*h,o*Math.exp(lp*t*r)]};else{var d=Math.sqrt(f),p=(u*u-o*o+4*f)/(2*o*2*d),y=(u*u-o*o-4*f)/(2*u*2*d),g=Math.log(Math.sqrt(p*p+1)-p),v=Math.log(Math.sqrt(y*y+1)-y);r=(v-g)/lp,n=function(t){var e,n=t*r,s=hp(g),c=o/(2*d)*(s*(e=lp*n+g,((e=Math.exp(2*e))-1)/(e+1))-function(t){return((t=Math.exp(t))-1/t)/2}(g));return[i+c*l,a+c*h,o*s/hp(lp*n+g)]}}return n.duration=1e3*r,n};function dp(t){return function(e,n){var r=t((e=tn(e)).h,(n=tn(n)).h),i=hn(e.s,n.s),a=hn(e.l,n.l),o=hn(e.opacity,n.opacity);return function(t){return e.h=r(t),e.s=i(t),e.l=a(t),e.opacity=o(t),e+""}}}var pp=dp(un),yp=dp(hn);function gp(t,e){var n=hn((t=pa(t)).l,(e=pa(e)).l),r=hn(t.a,e.a),i=hn(t.b,e.b),a=hn(t.opacity,e.opacity);return function(e){return t.l=n(e),t.a=r(e),t.b=i(e),t.opacity=a(e),t+""}}function vp(t){return function(e,n){var r=t((e=ka(e)).h,(n=ka(n)).h),i=hn(e.c,n.c),a=hn(e.l,n.l),o=hn(e.opacity,n.opacity);return function(t){return e.h=r(t),e.c=i(t),e.l=a(t),e.opacity=o(t),e+""}}}var mp=vp(un),bp=vp(hn);function xp(t){return function e(n){function r(e,r){var i=t((e=Oa(e)).h,(r=Oa(r)).h),a=hn(e.s,r.s),o=hn(e.l,r.l),s=hn(e.opacity,r.opacity);return function(t){return e.h=i(t),e.s=a(t),e.l=o(Math.pow(t,n)),e.opacity=s(t),e+""}}return n=+n,r.gamma=e,r}(1)}var _p=xp(un),kp=xp(hn);function wp(t,e){for(var n=0,r=e.length-1,i=e[0],a=new Array(r<0?0:r);n1&&(e=t[a[o-2]],n=t[a[o-1]],r=t[s],(n[0]-e[0])*(r[1]-e[1])-(n[1]-e[1])*(r[0]-e[0])<=0);)--o;a[o++]=s}return a.slice(0,o)}var Mp=function(t){if((n=t.length)<3)return null;var e,n,r=new Array(n),i=new Array(n);for(e=0;e=0;--e)u.push(t[r[a[e]][2]]);for(e=+s;es!=u>s&&o<(c-n)*(s-r)/(u-r)+n&&(l=!l),c=n,u=r;return l},Dp=function(t){for(var e,n,r=-1,i=t.length,a=t[i-1],o=a[0],s=a[1],c=0;++r1);return t+n*a*Math.sqrt(-2*Math.log(i)/i)}}return n.source=t,n}(Np),Pp=function t(e){function n(){var t=Lp.source(e).apply(this,arguments);return function(){return Math.exp(t())}}return n.source=t,n}(Np),Ip=function t(e){function n(t){return function(){for(var n=0,r=0;rr&&(e=n,n=r,r=e),function(t){return Math.max(n,Math.min(r,t))}}function ty(t,e,n){var r=t[0],i=t[1],a=e[0],o=e[1];return i2?ey:ty,i=a=null,h}function h(e){return isNaN(e=+e)?n:(i||(i=r(o.map(t),s,c)))(t(u(e)))}return h.invert=function(n){return u(e((a||(a=r(s,o.map(t),_n)))(n)))},h.domain=function(t){return arguments.length?(o=Up.call(t,Xp),u===Jp||(u=Qp(o)),l()):o.slice()},h.range=function(t){return arguments.length?(s=$p.call(t),l()):s.slice()},h.rangeRound=function(t){return s=$p.call(t),c=up,l()},h.clamp=function(t){return arguments.length?(u=t?Qp(o):Jp,h):u!==Jp},h.interpolate=function(t){return arguments.length?(c=t,l()):c},h.unknown=function(t){return arguments.length?(n=t,h):n},function(n,r){return t=n,e=r,l()}}function iy(t,e){return ry()(t,e)}var ay=function(t,e,n,r){var i,a=A(t,e,n);switch((r=Hs(null==r?",f":r)).type){case"s":var o=Math.max(Math.abs(t),Math.abs(e));return null!=r.precision||isNaN(i=ac(a,o))||(r.precision=i),Zs(r,o);case"":case"e":case"g":case"p":case"r":null!=r.precision||isNaN(i=oc(a,Math.max(Math.abs(t),Math.abs(e))))||(r.precision=i-("e"===r.type));break;case"f":case"%":null!=r.precision||isNaN(i=ic(a))||(r.precision=i-2*("%"===r.type))}return Xs(r)};function oy(t){var e=t.domain;return t.ticks=function(t){var n=e();return C(n[0],n[n.length-1],null==t?10:t)},t.tickFormat=function(t,n){var r=e();return ay(r[0],r[r.length-1],null==t?10:t,n)},t.nice=function(n){null==n&&(n=10);var r,i=e(),a=0,o=i.length-1,s=i[a],c=i[o];return c0?r=S(s=Math.floor(s/r)*r,c=Math.ceil(c/r)*r,n):r<0&&(r=S(s=Math.ceil(s*r)/r,c=Math.floor(c*r)/r,n)),r>0?(i[a]=Math.floor(s/r)*r,i[o]=Math.ceil(c/r)*r,e(i)):r<0&&(i[a]=Math.ceil(s*r)/r,i[o]=Math.floor(c*r)/r,e(i)),t},t}function sy(){var t=iy(Jp,Jp);return t.copy=function(){return ny(t,sy())},Rp.apply(t,arguments),oy(t)}function cy(t){var e;function n(t){return isNaN(t=+t)?e:t}return n.invert=n,n.domain=n.range=function(e){return arguments.length?(t=Up.call(e,Xp),n):t.slice()},n.unknown=function(t){return arguments.length?(e=t,n):e},n.copy=function(){return cy(t).unknown(e)},t=arguments.length?Up.call(t,Xp):[0,1],oy(n)}var uy=function(t,e){var n,r=0,i=(t=t.slice()).length-1,a=t[r],o=t[i];return o0){for(;fc)break;y.push(h)}}else for(;f=1;--l)if(!((h=u*l)c)break;y.push(h)}}else y=C(f,d,Math.min(d-f,p)).map(n);return r?y.reverse():y},r.tickFormat=function(t,i){if(null==i&&(i=10===a?".0e":","),"function"!=typeof i&&(i=Xs(i)),t===1/0)return i;null==t&&(t=10);var o=Math.max(1,a*t/r.ticks().length);return function(t){var r=t/n(Math.round(e(t)));return r*a0?i[r-1]:e[0],r=r?[i[r-1],n]:[i[o-1],i[o]]},o.unknown=function(e){return arguments.length?(t=e,o):o},o.thresholds=function(){return i.slice()},o.copy=function(){return My().domain([e,n]).range(a).unknown(t)},Rp.apply(oy(o),arguments)}function Oy(){var t,e=[.5],n=[0,1],r=1;function i(i){return i<=i?n[c(e,i,0,r)]:t}return i.domain=function(t){return arguments.length?(e=$p.call(t),r=Math.min(e.length,n.length-1),i):e.slice()},i.range=function(t){return arguments.length?(n=$p.call(t),r=Math.min(e.length,n.length-1),i):n.slice()},i.invertExtent=function(t){var r=n.indexOf(t);return[e[r-1],e[r]]},i.unknown=function(e){return arguments.length?(t=e,i):t},i.copy=function(){return Oy().domain(e).range(n).unknown(t)},Rp.apply(i,arguments)}var Dy=new Date,Ny=new Date;function By(t,e,n,r){function i(e){return t(e=0===arguments.length?new Date:new Date(+e)),e}return i.floor=function(e){return t(e=new Date(+e)),e},i.ceil=function(n){return t(n=new Date(n-1)),e(n,1),t(n),n},i.round=function(t){var e=i(t),n=i.ceil(t);return t-e0))return s;do{s.push(o=new Date(+n)),e(n,a),t(n)}while(o=e)for(;t(e),!n(e);)e.setTime(e-1)}),(function(t,r){if(t>=t)if(r<0)for(;++r<=0;)for(;e(t,-1),!n(t););else for(;--r>=0;)for(;e(t,1),!n(t););}))},n&&(i.count=function(e,r){return Dy.setTime(+e),Ny.setTime(+r),t(Dy),t(Ny),Math.floor(n(Dy,Ny))},i.every=function(t){return t=Math.floor(t),isFinite(t)&&t>0?t>1?i.filter(r?function(e){return r(e)%t==0}:function(e){return i.count(0,e)%t==0}):i:null}),i}var Ly=By((function(t){t.setMonth(0,1),t.setHours(0,0,0,0)}),(function(t,e){t.setFullYear(t.getFullYear()+e)}),(function(t,e){return e.getFullYear()-t.getFullYear()}),(function(t){return t.getFullYear()}));Ly.every=function(t){return isFinite(t=Math.floor(t))&&t>0?By((function(e){e.setFullYear(Math.floor(e.getFullYear()/t)*t),e.setMonth(0,1),e.setHours(0,0,0,0)}),(function(e,n){e.setFullYear(e.getFullYear()+n*t)})):null};var Py=Ly,Iy=Ly.range,Fy=By((function(t){t.setDate(1),t.setHours(0,0,0,0)}),(function(t,e){t.setMonth(t.getMonth()+e)}),(function(t,e){return e.getMonth()-t.getMonth()+12*(e.getFullYear()-t.getFullYear())}),(function(t){return t.getMonth()})),jy=Fy,Ry=Fy.range;function Yy(t){return By((function(e){e.setDate(e.getDate()-(e.getDay()+7-t)%7),e.setHours(0,0,0,0)}),(function(t,e){t.setDate(t.getDate()+7*e)}),(function(t,e){return(e-t-6e4*(e.getTimezoneOffset()-t.getTimezoneOffset()))/6048e5}))}var zy=Yy(0),Uy=Yy(1),$y=Yy(2),Wy=Yy(3),Hy=Yy(4),Vy=Yy(5),Gy=Yy(6),qy=zy.range,Xy=Uy.range,Zy=$y.range,Jy=Wy.range,Ky=Hy.range,Qy=Vy.range,tg=Gy.range,eg=By((function(t){t.setHours(0,0,0,0)}),(function(t,e){t.setDate(t.getDate()+e)}),(function(t,e){return(e-t-6e4*(e.getTimezoneOffset()-t.getTimezoneOffset()))/864e5}),(function(t){return t.getDate()-1})),ng=eg,rg=eg.range,ig=By((function(t){t.setTime(t-t.getMilliseconds()-1e3*t.getSeconds()-6e4*t.getMinutes())}),(function(t,e){t.setTime(+t+36e5*e)}),(function(t,e){return(e-t)/36e5}),(function(t){return t.getHours()})),ag=ig,og=ig.range,sg=By((function(t){t.setTime(t-t.getMilliseconds()-1e3*t.getSeconds())}),(function(t,e){t.setTime(+t+6e4*e)}),(function(t,e){return(e-t)/6e4}),(function(t){return t.getMinutes()})),cg=sg,ug=sg.range,lg=By((function(t){t.setTime(t-t.getMilliseconds())}),(function(t,e){t.setTime(+t+1e3*e)}),(function(t,e){return(e-t)/1e3}),(function(t){return t.getUTCSeconds()})),hg=lg,fg=lg.range,dg=By((function(){}),(function(t,e){t.setTime(+t+e)}),(function(t,e){return e-t}));dg.every=function(t){return t=Math.floor(t),isFinite(t)&&t>0?t>1?By((function(e){e.setTime(Math.floor(e/t)*t)}),(function(e,n){e.setTime(+e+n*t)}),(function(e,n){return(n-e)/t})):dg:null};var pg=dg,yg=dg.range;function gg(t){return By((function(e){e.setUTCDate(e.getUTCDate()-(e.getUTCDay()+7-t)%7),e.setUTCHours(0,0,0,0)}),(function(t,e){t.setUTCDate(t.getUTCDate()+7*e)}),(function(t,e){return(e-t)/6048e5}))}var vg=gg(0),mg=gg(1),bg=gg(2),xg=gg(3),_g=gg(4),kg=gg(5),wg=gg(6),Eg=vg.range,Tg=mg.range,Cg=bg.range,Sg=xg.range,Ag=_g.range,Mg=kg.range,Og=wg.range,Dg=By((function(t){t.setUTCHours(0,0,0,0)}),(function(t,e){t.setUTCDate(t.getUTCDate()+e)}),(function(t,e){return(e-t)/864e5}),(function(t){return t.getUTCDate()-1})),Ng=Dg,Bg=Dg.range,Lg=By((function(t){t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)}),(function(t,e){t.setUTCFullYear(t.getUTCFullYear()+e)}),(function(t,e){return e.getUTCFullYear()-t.getUTCFullYear()}),(function(t){return t.getUTCFullYear()}));Lg.every=function(t){return isFinite(t=Math.floor(t))&&t>0?By((function(e){e.setUTCFullYear(Math.floor(e.getUTCFullYear()/t)*t),e.setUTCMonth(0,1),e.setUTCHours(0,0,0,0)}),(function(e,n){e.setUTCFullYear(e.getUTCFullYear()+n*t)})):null};var Pg=Lg,Ig=Lg.range;function Fg(t){if(0<=t.y&&t.y<100){var e=new Date(-1,t.m,t.d,t.H,t.M,t.S,t.L);return e.setFullYear(t.y),e}return new Date(t.y,t.m,t.d,t.H,t.M,t.S,t.L)}function jg(t){if(0<=t.y&&t.y<100){var e=new Date(Date.UTC(-1,t.m,t.d,t.H,t.M,t.S,t.L));return e.setUTCFullYear(t.y),e}return new Date(Date.UTC(t.y,t.m,t.d,t.H,t.M,t.S,t.L))}function Rg(t,e,n){return{y:t,m:e,d:n,H:0,M:0,S:0,L:0}}function Yg(t){var e=t.dateTime,n=t.date,r=t.time,i=t.periods,a=t.days,o=t.shortDays,s=t.months,c=t.shortMonths,u=Kg(i),l=Qg(i),h=Kg(a),f=Qg(a),d=Kg(o),p=Qg(o),y=Kg(s),g=Qg(s),v=Kg(c),m=Qg(c),b={a:function(t){return o[t.getDay()]},A:function(t){return a[t.getDay()]},b:function(t){return c[t.getMonth()]},B:function(t){return s[t.getMonth()]},c:null,d:xv,e:xv,f:Tv,H:_v,I:kv,j:wv,L:Ev,m:Cv,M:Sv,p:function(t){return i[+(t.getHours()>=12)]},q:function(t){return 1+~~(t.getMonth()/3)},Q:em,s:nm,S:Av,u:Mv,U:Ov,V:Dv,w:Nv,W:Bv,x:null,X:null,y:Lv,Y:Pv,Z:Iv,"%":tm},x={a:function(t){return o[t.getUTCDay()]},A:function(t){return a[t.getUTCDay()]},b:function(t){return c[t.getUTCMonth()]},B:function(t){return s[t.getUTCMonth()]},c:null,d:Fv,e:Fv,f:Uv,H:jv,I:Rv,j:Yv,L:zv,m:$v,M:Wv,p:function(t){return i[+(t.getUTCHours()>=12)]},q:function(t){return 1+~~(t.getUTCMonth()/3)},Q:em,s:nm,S:Hv,u:Vv,U:Gv,V:qv,w:Xv,W:Zv,x:null,X:null,y:Jv,Y:Kv,Z:Qv,"%":tm},_={a:function(t,e,n){var r=d.exec(e.slice(n));return r?(t.w=p[r[0].toLowerCase()],n+r[0].length):-1},A:function(t,e,n){var r=h.exec(e.slice(n));return r?(t.w=f[r[0].toLowerCase()],n+r[0].length):-1},b:function(t,e,n){var r=v.exec(e.slice(n));return r?(t.m=m[r[0].toLowerCase()],n+r[0].length):-1},B:function(t,e,n){var r=y.exec(e.slice(n));return r?(t.m=g[r[0].toLowerCase()],n+r[0].length):-1},c:function(t,n,r){return E(t,e,n,r)},d:lv,e:lv,f:gv,H:fv,I:fv,j:hv,L:yv,m:uv,M:dv,p:function(t,e,n){var r=u.exec(e.slice(n));return r?(t.p=l[r[0].toLowerCase()],n+r[0].length):-1},q:cv,Q:mv,s:bv,S:pv,u:ev,U:nv,V:rv,w:tv,W:iv,x:function(t,e,r){return E(t,n,e,r)},X:function(t,e,n){return E(t,r,e,n)},y:ov,Y:av,Z:sv,"%":vv};function k(t,e){return function(n){var r,i,a,o=[],s=-1,c=0,u=t.length;for(n instanceof Date||(n=new Date(+n));++s53)return null;"w"in a||(a.w=1),"Z"in a?(i=(r=jg(Rg(a.y,0,1))).getUTCDay(),r=i>4||0===i?mg.ceil(r):mg(r),r=Ng.offset(r,7*(a.V-1)),a.y=r.getUTCFullYear(),a.m=r.getUTCMonth(),a.d=r.getUTCDate()+(a.w+6)%7):(i=(r=Fg(Rg(a.y,0,1))).getDay(),r=i>4||0===i?Uy.ceil(r):Uy(r),r=ng.offset(r,7*(a.V-1)),a.y=r.getFullYear(),a.m=r.getMonth(),a.d=r.getDate()+(a.w+6)%7)}else("W"in a||"U"in a)&&("w"in a||(a.w="u"in a?a.u%7:"W"in a?1:0),i="Z"in a?jg(Rg(a.y,0,1)).getUTCDay():Fg(Rg(a.y,0,1)).getDay(),a.m=0,a.d="W"in a?(a.w+6)%7+7*a.W-(i+5)%7:a.w+7*a.U-(i+6)%7);return"Z"in a?(a.H+=a.Z/100|0,a.M+=a.Z%100,jg(a)):Fg(a)}}function E(t,e,n,r){for(var i,a,o=0,s=e.length,c=n.length;o=c)return-1;if(37===(i=e.charCodeAt(o++))){if(i=e.charAt(o++),!(a=_[i in Vg?e.charAt(o++):i])||(r=a(t,n,r))<0)return-1}else if(i!=n.charCodeAt(r++))return-1}return r}return(b.x=k(n,b),b.X=k(r,b),b.c=k(e,b),x.x=k(n,x),x.X=k(r,x),x.c=k(e,x),{format:function(t){var e=k(t+="",b);return e.toString=function(){return t},e},parse:function(t){var e=w(t+="",!1);return e.toString=function(){return t},e},utcFormat:function(t){var e=k(t+="",x);return e.toString=function(){return t},e},utcParse:function(t){var e=w(t+="",!0);return e.toString=function(){return t},e}})}var zg,Ug,$g,Wg,Hg,Vg={"-":"",_:" ",0:"0"},Gg=/^\s*\d+/,qg=/^%/,Xg=/[\\^$*+?|[\]().{}]/g;function Zg(t,e,n){var r=t<0?"-":"",i=(r?-t:t)+"",a=i.length;return r+(a68?1900:2e3),n+r[0].length):-1}function sv(t,e,n){var r=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(e.slice(n,n+6));return r?(t.Z=r[1]?0:-(r[2]+(r[3]||"00")),n+r[0].length):-1}function cv(t,e,n){var r=Gg.exec(e.slice(n,n+1));return r?(t.q=3*r[0]-3,n+r[0].length):-1}function uv(t,e,n){var r=Gg.exec(e.slice(n,n+2));return r?(t.m=r[0]-1,n+r[0].length):-1}function lv(t,e,n){var r=Gg.exec(e.slice(n,n+2));return r?(t.d=+r[0],n+r[0].length):-1}function hv(t,e,n){var r=Gg.exec(e.slice(n,n+3));return r?(t.m=0,t.d=+r[0],n+r[0].length):-1}function fv(t,e,n){var r=Gg.exec(e.slice(n,n+2));return r?(t.H=+r[0],n+r[0].length):-1}function dv(t,e,n){var r=Gg.exec(e.slice(n,n+2));return r?(t.M=+r[0],n+r[0].length):-1}function pv(t,e,n){var r=Gg.exec(e.slice(n,n+2));return r?(t.S=+r[0],n+r[0].length):-1}function yv(t,e,n){var r=Gg.exec(e.slice(n,n+3));return r?(t.L=+r[0],n+r[0].length):-1}function gv(t,e,n){var r=Gg.exec(e.slice(n,n+6));return r?(t.L=Math.floor(r[0]/1e3),n+r[0].length):-1}function vv(t,e,n){var r=qg.exec(e.slice(n,n+1));return r?n+r[0].length:-1}function mv(t,e,n){var r=Gg.exec(e.slice(n));return r?(t.Q=+r[0],n+r[0].length):-1}function bv(t,e,n){var r=Gg.exec(e.slice(n));return r?(t.s=+r[0],n+r[0].length):-1}function xv(t,e){return Zg(t.getDate(),e,2)}function _v(t,e){return Zg(t.getHours(),e,2)}function kv(t,e){return Zg(t.getHours()%12||12,e,2)}function wv(t,e){return Zg(1+ng.count(Py(t),t),e,3)}function Ev(t,e){return Zg(t.getMilliseconds(),e,3)}function Tv(t,e){return Ev(t,e)+"000"}function Cv(t,e){return Zg(t.getMonth()+1,e,2)}function Sv(t,e){return Zg(t.getMinutes(),e,2)}function Av(t,e){return Zg(t.getSeconds(),e,2)}function Mv(t){var e=t.getDay();return 0===e?7:e}function Ov(t,e){return Zg(zy.count(Py(t)-1,t),e,2)}function Dv(t,e){var n=t.getDay();return t=n>=4||0===n?Hy(t):Hy.ceil(t),Zg(Hy.count(Py(t),t)+(4===Py(t).getDay()),e,2)}function Nv(t){return t.getDay()}function Bv(t,e){return Zg(Uy.count(Py(t)-1,t),e,2)}function Lv(t,e){return Zg(t.getFullYear()%100,e,2)}function Pv(t,e){return Zg(t.getFullYear()%1e4,e,4)}function Iv(t){var e=t.getTimezoneOffset();return(e>0?"-":(e*=-1,"+"))+Zg(e/60|0,"0",2)+Zg(e%60,"0",2)}function Fv(t,e){return Zg(t.getUTCDate(),e,2)}function jv(t,e){return Zg(t.getUTCHours(),e,2)}function Rv(t,e){return Zg(t.getUTCHours()%12||12,e,2)}function Yv(t,e){return Zg(1+Ng.count(Pg(t),t),e,3)}function zv(t,e){return Zg(t.getUTCMilliseconds(),e,3)}function Uv(t,e){return zv(t,e)+"000"}function $v(t,e){return Zg(t.getUTCMonth()+1,e,2)}function Wv(t,e){return Zg(t.getUTCMinutes(),e,2)}function Hv(t,e){return Zg(t.getUTCSeconds(),e,2)}function Vv(t){var e=t.getUTCDay();return 0===e?7:e}function Gv(t,e){return Zg(vg.count(Pg(t)-1,t),e,2)}function qv(t,e){var n=t.getUTCDay();return t=n>=4||0===n?_g(t):_g.ceil(t),Zg(_g.count(Pg(t),t)+(4===Pg(t).getUTCDay()),e,2)}function Xv(t){return t.getUTCDay()}function Zv(t,e){return Zg(mg.count(Pg(t)-1,t),e,2)}function Jv(t,e){return Zg(t.getUTCFullYear()%100,e,2)}function Kv(t,e){return Zg(t.getUTCFullYear()%1e4,e,4)}function Qv(){return"+0000"}function tm(){return"%"}function em(t){return+t}function nm(t){return Math.floor(+t/1e3)}function rm(t){return zg=Yg(t),Ug=zg.format,$g=zg.parse,Wg=zg.utcFormat,Hg=zg.utcParse,zg}rm({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});function im(t){return new Date(t)}function am(t){return t instanceof Date?+t:+new Date(+t)}function om(t,e,n,r,a,o,s,c,u){var l=iy(Jp,Jp),h=l.invert,f=l.domain,d=u(".%L"),p=u(":%S"),y=u("%I:%M"),g=u("%I %p"),v=u("%a %d"),m=u("%b %d"),b=u("%B"),x=u("%Y"),_=[[s,1,1e3],[s,5,5e3],[s,15,15e3],[s,30,3e4],[o,1,6e4],[o,5,3e5],[o,15,9e5],[o,30,18e5],[a,1,36e5],[a,3,108e5],[a,6,216e5],[a,12,432e5],[r,1,864e5],[r,2,1728e5],[n,1,6048e5],[e,1,2592e6],[e,3,7776e6],[t,1,31536e6]];function k(i){return(s(i)1)&&(t-=Math.floor(t));var e=Math.abs(t-.5);return qb.h=360*t-100,qb.s=1.5-1.5*e,qb.l=.8-.9*e,qb+""},Zb=Ge(),Jb=Math.PI/3,Kb=2*Math.PI/3,Qb=function(t){var e;return t=(.5-t)*Math.PI,Zb.r=255*(e=Math.sin(t))*e,Zb.g=255*(e=Math.sin(t+Jb))*e,Zb.b=255*(e=Math.sin(t+Kb))*e,Zb+""},tx=function(t){return t=Math.max(0,Math.min(1,t)),"rgb("+Math.max(0,Math.min(255,Math.round(34.61+t*(1172.33-t*(10793.56-t*(33300.12-t*(38394.49-14825.05*t)))))))+", "+Math.max(0,Math.min(255,Math.round(23.31+t*(557.33+t*(1225.33-t*(3574.96-t*(1073.77+707.56*t)))))))+", "+Math.max(0,Math.min(255,Math.round(27.2+t*(3211.1-t*(15327.97-t*(27814-t*(22569.18-6838.66*t)))))))+")"};function ex(t){var e=t.length;return function(n){return t[Math.max(0,Math.min(e-1,Math.floor(n*e)))]}}var nx=ex(Nm("44015444025645045745055946075a46085c460a5d460b5e470d60470e6147106347116447136548146748166848176948186a481a6c481b6d481c6e481d6f481f70482071482173482374482475482576482677482878482979472a7a472c7a472d7b472e7c472f7d46307e46327e46337f463480453581453781453882443983443a83443b84433d84433e85423f854240864241864142874144874045884046883f47883f48893e49893e4a893e4c8a3d4d8a3d4e8a3c4f8a3c508b3b518b3b528b3a538b3a548c39558c39568c38588c38598c375a8c375b8d365c8d365d8d355e8d355f8d34608d34618d33628d33638d32648e32658e31668e31678e31688e30698e306a8e2f6b8e2f6c8e2e6d8e2e6e8e2e6f8e2d708e2d718e2c718e2c728e2c738e2b748e2b758e2a768e2a778e2a788e29798e297a8e297b8e287c8e287d8e277e8e277f8e27808e26818e26828e26828e25838e25848e25858e24868e24878e23888e23898e238a8d228b8d228c8d228d8d218e8d218f8d21908d21918c20928c20928c20938c1f948c1f958b1f968b1f978b1f988b1f998a1f9a8a1e9b8a1e9c891e9d891f9e891f9f881fa0881fa1881fa1871fa28720a38620a48621a58521a68522a78522a88423a98324aa8325ab8225ac8226ad8127ad8128ae8029af7f2ab07f2cb17e2db27d2eb37c2fb47c31b57b32b67a34b67935b77937b87838b9773aba763bbb753dbc743fbc7340bd7242be7144bf7046c06f48c16e4ac16d4cc26c4ec36b50c46a52c56954c56856c66758c7655ac8645cc8635ec96260ca6063cb5f65cb5e67cc5c69cd5b6ccd5a6ece5870cf5773d05675d05477d1537ad1517cd2507fd34e81d34d84d44b86d54989d5488bd6468ed64590d74393d74195d84098d83e9bd93c9dd93ba0da39a2da37a5db36a8db34aadc32addc30b0dd2fb2dd2db5de2bb8de29bade28bddf26c0df25c2df23c5e021c8e020cae11fcde11dd0e11cd2e21bd5e21ad8e219dae319dde318dfe318e2e418e5e419e7e419eae51aece51befe51cf1e51df4e61ef6e620f8e621fbe723fde725")),rx=ex(Nm("00000401000501010601010802010902020b02020d03030f03031204041405041606051806051a07061c08071e0907200a08220b09240c09260d0a290e0b2b100b2d110c2f120d31130d34140e36150e38160f3b180f3d19103f1a10421c10441d11471e114920114b21114e22115024125325125527125829115a2a115c2c115f2d11612f116331116533106734106936106b38106c390f6e3b0f703d0f713f0f72400f74420f75440f764510774710784910784a10794c117a4e117b4f127b51127c52137c54137d56147d57157e59157e5a167e5c167f5d177f5f187f601880621980641a80651a80671b80681c816a1c816b1d816d1d816e1e81701f81721f817320817521817621817822817922827b23827c23827e24828025828125818326818426818627818827818928818b29818c29818e2a81902a81912b81932b80942c80962c80982d80992d809b2e7f9c2e7f9e2f7fa02f7fa1307ea3307ea5317ea6317da8327daa337dab337cad347cae347bb0357bb2357bb3367ab5367ab73779b83779ba3878bc3978bd3977bf3a77c03a76c23b75c43c75c53c74c73d73c83e73ca3e72cc3f71cd4071cf4070d0416fd2426fd3436ed5446dd6456cd8456cd9466bdb476adc4869de4968df4a68e04c67e24d66e34e65e44f64e55064e75263e85362e95462ea5661eb5760ec5860ed5a5fee5b5eef5d5ef05f5ef1605df2625df2645cf3655cf4675cf4695cf56b5cf66c5cf66e5cf7705cf7725cf8745cf8765cf9785df9795df97b5dfa7d5efa7f5efa815ffb835ffb8560fb8761fc8961fc8a62fc8c63fc8e64fc9065fd9266fd9467fd9668fd9869fd9a6afd9b6bfe9d6cfe9f6dfea16efea36ffea571fea772fea973feaa74feac76feae77feb078feb27afeb47bfeb67cfeb77efeb97ffebb81febd82febf84fec185fec287fec488fec68afec88cfeca8dfecc8ffecd90fecf92fed194fed395fed597fed799fed89afdda9cfddc9efddea0fde0a1fde2a3fde3a5fde5a7fde7a9fde9aafdebacfcecaefceeb0fcf0b2fcf2b4fcf4b6fcf6b8fcf7b9fcf9bbfcfbbdfcfdbf")),ix=ex(Nm("00000401000501010601010802010a02020c02020e03021004031204031405041706041907051b08051d09061f0a07220b07240c08260d08290e092b10092d110a30120a32140b34150b37160b39180c3c190c3e1b0c411c0c431e0c451f0c48210c4a230c4c240c4f260c51280b53290b552b0b572d0b592f0a5b310a5c320a5e340a5f3609613809623909633b09643d09653e0966400a67420a68440a68450a69470b6a490b6a4a0c6b4c0c6b4d0d6c4f0d6c510e6c520e6d540f6d550f6d57106e59106e5a116e5c126e5d126e5f136e61136e62146e64156e65156e67166e69166e6a176e6c186e6d186e6f196e71196e721a6e741a6e751b6e771c6d781c6d7a1d6d7c1d6d7d1e6d7f1e6c801f6c82206c84206b85216b87216b88226a8a226a8c23698d23698f24699025689225689326679526679727669827669a28659b29649d29649f2a63a02a63a22b62a32c61a52c60a62d60a82e5fa92e5eab2f5ead305dae305cb0315bb1325ab3325ab43359b63458b73557b93556ba3655bc3754bd3853bf3952c03a51c13a50c33b4fc43c4ec63d4dc73e4cc83f4bca404acb4149cc4248ce4347cf4446d04545d24644d34743d44842d54a41d74b3fd84c3ed94d3dda4e3cdb503bdd513ade5238df5337e05536e15635e25734e35933e45a31e55c30e65d2fe75e2ee8602de9612bea632aeb6429eb6628ec6726ed6925ee6a24ef6c23ef6e21f06f20f1711ff1731df2741cf3761bf37819f47918f57b17f57d15f67e14f68013f78212f78410f8850ff8870ef8890cf98b0bf98c0af98e09fa9008fa9207fa9407fb9606fb9706fb9906fb9b06fb9d07fc9f07fca108fca309fca50afca60cfca80dfcaa0ffcac11fcae12fcb014fcb216fcb418fbb61afbb81dfbba1ffbbc21fbbe23fac026fac228fac42afac62df9c72ff9c932f9cb35f8cd37f8cf3af7d13df7d340f6d543f6d746f5d949f5db4cf4dd4ff4df53f4e156f3e35af3e55df2e661f2e865f2ea69f1ec6df1ed71f1ef75f1f179f2f27df2f482f3f586f3f68af4f88ef5f992f6fa96f8fb9af9fc9dfafda1fcffa4")),ax=ex(Nm("0d088710078813078916078a19068c1b068d1d068e20068f2206902406912605912805922a05932c05942e05952f059631059733059735049837049938049a3a049a3c049b3e049c3f049c41049d43039e44039e46039f48039f4903a04b03a14c02a14e02a25002a25102a35302a35502a45601a45801a45901a55b01a55c01a65e01a66001a66100a76300a76400a76600a76700a86900a86a00a86c00a86e00a86f00a87100a87201a87401a87501a87701a87801a87a02a87b02a87d03a87e03a88004a88104a78305a78405a78606a68707a68808a68a09a58b0aa58d0ba58e0ca48f0da4910ea3920fa39410a29511a19613a19814a099159f9a169f9c179e9d189d9e199da01a9ca11b9ba21d9aa31e9aa51f99a62098a72197a82296aa2395ab2494ac2694ad2793ae2892b02991b12a90b22b8fb32c8eb42e8db52f8cb6308bb7318ab83289ba3388bb3488bc3587bd3786be3885bf3984c03a83c13b82c23c81c33d80c43e7fc5407ec6417dc7427cc8437bc9447aca457acb4679cc4778cc4977cd4a76ce4b75cf4c74d04d73d14e72d24f71d35171d45270d5536fd5546ed6556dd7566cd8576bd9586ada5a6ada5b69db5c68dc5d67dd5e66de5f65de6164df6263e06363e16462e26561e26660e3685fe4695ee56a5de56b5de66c5ce76e5be76f5ae87059e97158e97257ea7457eb7556eb7655ec7754ed7953ed7a52ee7b51ef7c51ef7e50f07f4ff0804ef1814df1834cf2844bf3854bf3874af48849f48948f58b47f58c46f68d45f68f44f79044f79143f79342f89441f89540f9973ff9983ef99a3efa9b3dfa9c3cfa9e3bfb9f3afba139fba238fca338fca537fca636fca835fca934fdab33fdac33fdae32fdaf31fdb130fdb22ffdb42ffdb52efeb72dfeb82cfeba2cfebb2bfebd2afebe2afec029fdc229fdc328fdc527fdc627fdc827fdca26fdcb26fccd25fcce25fcd025fcd225fbd324fbd524fbd724fad824fada24f9dc24f9dd25f8df25f8e125f7e225f7e425f6e626f6e826f5e926f5eb27f4ed27f3ee27f3f027f2f227f1f426f1f525f0f724f0f921")),ox=function(t){return ke(ne(t).call(document.documentElement))},sx=0;function cx(){return new ux}function ux(){this._="@"+(++sx).toString(36)}ux.prototype=cx.prototype={constructor:ux,get:function(t){for(var e=this._;!(e in t);)if(!(t=t.parentNode))return;return t[e]},set:function(t,e){return t[this._]=e},remove:function(t){return this._ in t&&delete t[this._]},toString:function(){return this._}};var lx=function(t){return"string"==typeof t?new be([document.querySelectorAll(t)],[document.documentElement]):new be([null==t?[]:t],me)},hx=function(t,e){null==e&&(e=Mn().touches);for(var n=0,r=e?e.length:0,i=new Array(r);n1?0:t<-1?xx:Math.acos(t)}function Ex(t){return t>=1?_x:t<=-1?-_x:Math.asin(t)}function Tx(t){return t.innerRadius}function Cx(t){return t.outerRadius}function Sx(t){return t.startAngle}function Ax(t){return t.endAngle}function Mx(t){return t&&t.padAngle}function Ox(t,e,n,r,i,a,o,s){var c=n-t,u=r-e,l=o-i,h=s-a,f=h*c-l*u;if(!(f*f<1e-12))return[t+(f=(l*(e-a)-h*(t-i))/f)*c,e+f*u]}function Dx(t,e,n,r,i,a,o){var s=t-n,c=e-r,u=(o?a:-a)/bx(s*s+c*c),l=u*c,h=-u*s,f=t+l,d=e+h,p=n+l,y=r+h,g=(f+p)/2,v=(d+y)/2,m=p-f,b=y-d,x=m*m+b*b,_=i-a,k=f*y-p*d,w=(b<0?-1:1)*bx(gx(0,_*_*x-k*k)),E=(k*b-m*w)/x,T=(-k*m-b*w)/x,C=(k*b+m*w)/x,S=(-k*m+b*w)/x,A=E-g,M=T-v,O=C-g,D=S-v;return A*A+M*M>O*O+D*D&&(E=C,T=S),{cx:E,cy:T,x01:-l,y01:-h,x11:E*(i/_-1),y11:T*(i/_-1)}}var Nx=function(){var t=Tx,e=Cx,n=fx(0),r=null,i=Sx,a=Ax,o=Mx,s=null;function c(){var c,u,l=+t.apply(this,arguments),h=+e.apply(this,arguments),f=i.apply(this,arguments)-_x,d=a.apply(this,arguments)-_x,p=dx(d-f),y=d>f;if(s||(s=c=Ui()),h1e-12)if(p>kx-1e-12)s.moveTo(h*yx(f),h*mx(f)),s.arc(0,0,h,f,d,!y),l>1e-12&&(s.moveTo(l*yx(d),l*mx(d)),s.arc(0,0,l,d,f,y));else{var g,v,m=f,b=d,x=f,_=d,k=p,w=p,E=o.apply(this,arguments)/2,T=E>1e-12&&(r?+r.apply(this,arguments):bx(l*l+h*h)),C=vx(dx(h-l)/2,+n.apply(this,arguments)),S=C,A=C;if(T>1e-12){var M=Ex(T/l*mx(E)),O=Ex(T/h*mx(E));(k-=2*M)>1e-12?(x+=M*=y?1:-1,_-=M):(k=0,x=_=(f+d)/2),(w-=2*O)>1e-12?(m+=O*=y?1:-1,b-=O):(w=0,m=b=(f+d)/2)}var D=h*yx(m),N=h*mx(m),B=l*yx(_),L=l*mx(_);if(C>1e-12){var P,I=h*yx(b),F=h*mx(b),j=l*yx(x),R=l*mx(x);if(p1e-12?A>1e-12?(g=Dx(j,R,D,N,h,A,y),v=Dx(I,F,B,L,h,A,y),s.moveTo(g.cx+g.x01,g.cy+g.y01),A1e-12&&k>1e-12?S>1e-12?(g=Dx(B,L,I,F,l,-S,y),v=Dx(D,N,j,R,l,-S,y),s.lineTo(g.cx+g.x01,g.cy+g.y01),S=l;--h)s.point(g[h],v[h]);s.lineEnd(),s.areaEnd()}y&&(g[u]=+t(f,u,c),v[u]=+n(f,u,c),s.point(e?+e(f,u,c):g[u],r?+r(f,u,c):v[u]))}if(d)return s=null,d+""||null}function u(){return Fx().defined(i).curve(o).context(a)}return c.x=function(n){return arguments.length?(t="function"==typeof n?n:fx(+n),e=null,c):t},c.x0=function(e){return arguments.length?(t="function"==typeof e?e:fx(+e),c):t},c.x1=function(t){return arguments.length?(e=null==t?null:"function"==typeof t?t:fx(+t),c):e},c.y=function(t){return arguments.length?(n="function"==typeof t?t:fx(+t),r=null,c):n},c.y0=function(t){return arguments.length?(n="function"==typeof t?t:fx(+t),c):n},c.y1=function(t){return arguments.length?(r=null==t?null:"function"==typeof t?t:fx(+t),c):r},c.lineX0=c.lineY0=function(){return u().x(t).y(n)},c.lineY1=function(){return u().x(t).y(r)},c.lineX1=function(){return u().x(e).y(n)},c.defined=function(t){return arguments.length?(i="function"==typeof t?t:fx(!!t),c):i},c.curve=function(t){return arguments.length?(o=t,null!=a&&(s=o(a)),c):o},c.context=function(t){return arguments.length?(null==t?a=s=null:s=o(a=t),c):a},c},Rx=function(t,e){return et?1:e>=t?0:NaN},Yx=function(t){return t},zx=function(){var t=Yx,e=Rx,n=null,r=fx(0),i=fx(kx),a=fx(0);function o(o){var s,c,u,l,h,f=o.length,d=0,p=new Array(f),y=new Array(f),g=+r.apply(this,arguments),v=Math.min(kx,Math.max(-kx,i.apply(this,arguments)-g)),m=Math.min(Math.abs(v)/f,a.apply(this,arguments)),b=m*(v<0?-1:1);for(s=0;s0&&(d+=h);for(null!=e?p.sort((function(t,n){return e(y[t],y[n])})):null!=n&&p.sort((function(t,e){return n(o[t],o[e])})),s=0,u=d?(v-f*b)/d:0;s0?h*u:0)+b,y[c]={data:o[c],index:s,value:h,startAngle:g,endAngle:l,padAngle:m};return y}return o.value=function(e){return arguments.length?(t="function"==typeof e?e:fx(+e),o):t},o.sortValues=function(t){return arguments.length?(e=t,n=null,o):e},o.sort=function(t){return arguments.length?(n=t,e=null,o):n},o.startAngle=function(t){return arguments.length?(r="function"==typeof t?t:fx(+t),o):r},o.endAngle=function(t){return arguments.length?(i="function"==typeof t?t:fx(+t),o):i},o.padAngle=function(t){return arguments.length?(a="function"==typeof t?t:fx(+t),o):a},o},Ux=Wx(Lx);function $x(t){this._curve=t}function Wx(t){function e(e){return new $x(t(e))}return e._curve=t,e}function Hx(t){var e=t.curve;return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t.curve=function(t){return arguments.length?e(Wx(t)):e()._curve},t}$x.prototype={areaStart:function(){this._curve.areaStart()},areaEnd:function(){this._curve.areaEnd()},lineStart:function(){this._curve.lineStart()},lineEnd:function(){this._curve.lineEnd()},point:function(t,e){this._curve.point(e*Math.sin(t),e*-Math.cos(t))}};var Vx=function(){return Hx(Fx().curve(Ux))},Gx=function(){var t=jx().curve(Ux),e=t.curve,n=t.lineX0,r=t.lineX1,i=t.lineY0,a=t.lineY1;return t.angle=t.x,delete t.x,t.startAngle=t.x0,delete t.x0,t.endAngle=t.x1,delete t.x1,t.radius=t.y,delete t.y,t.innerRadius=t.y0,delete t.y0,t.outerRadius=t.y1,delete t.y1,t.lineStartAngle=function(){return Hx(n())},delete t.lineX0,t.lineEndAngle=function(){return Hx(r())},delete t.lineX1,t.lineInnerRadius=function(){return Hx(i())},delete t.lineY0,t.lineOuterRadius=function(){return Hx(a())},delete t.lineY1,t.curve=function(t){return arguments.length?e(Wx(t)):e()._curve},t},qx=function(t,e){return[(e=+e)*Math.cos(t-=Math.PI/2),e*Math.sin(t)]},Xx=Array.prototype.slice;function Zx(t){return t.source}function Jx(t){return t.target}function Kx(t){var e=Zx,n=Jx,r=Px,i=Ix,a=null;function o(){var o,s=Xx.call(arguments),c=e.apply(this,s),u=n.apply(this,s);if(a||(a=o=Ui()),t(a,+r.apply(this,(s[0]=c,s)),+i.apply(this,s),+r.apply(this,(s[0]=u,s)),+i.apply(this,s)),o)return a=null,o+""||null}return o.source=function(t){return arguments.length?(e=t,o):e},o.target=function(t){return arguments.length?(n=t,o):n},o.x=function(t){return arguments.length?(r="function"==typeof t?t:fx(+t),o):r},o.y=function(t){return arguments.length?(i="function"==typeof t?t:fx(+t),o):i},o.context=function(t){return arguments.length?(a=null==t?null:t,o):a},o}function Qx(t,e,n,r,i){t.moveTo(e,n),t.bezierCurveTo(e=(e+r)/2,n,e,i,r,i)}function t_(t,e,n,r,i){t.moveTo(e,n),t.bezierCurveTo(e,n=(n+i)/2,r,n,r,i)}function e_(t,e,n,r,i){var a=qx(e,n),o=qx(e,n=(n+i)/2),s=qx(r,n),c=qx(r,i);t.moveTo(a[0],a[1]),t.bezierCurveTo(o[0],o[1],s[0],s[1],c[0],c[1])}function n_(){return Kx(Qx)}function r_(){return Kx(t_)}function i_(){var t=Kx(e_);return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t}var a_={draw:function(t,e){var n=Math.sqrt(e/xx);t.moveTo(n,0),t.arc(0,0,n,0,kx)}},o_={draw:function(t,e){var n=Math.sqrt(e/5)/2;t.moveTo(-3*n,-n),t.lineTo(-n,-n),t.lineTo(-n,-3*n),t.lineTo(n,-3*n),t.lineTo(n,-n),t.lineTo(3*n,-n),t.lineTo(3*n,n),t.lineTo(n,n),t.lineTo(n,3*n),t.lineTo(-n,3*n),t.lineTo(-n,n),t.lineTo(-3*n,n),t.closePath()}},s_=Math.sqrt(1/3),c_=2*s_,u_={draw:function(t,e){var n=Math.sqrt(e/c_),r=n*s_;t.moveTo(0,-n),t.lineTo(r,0),t.lineTo(0,n),t.lineTo(-r,0),t.closePath()}},l_=Math.sin(xx/10)/Math.sin(7*xx/10),h_=Math.sin(kx/10)*l_,f_=-Math.cos(kx/10)*l_,d_={draw:function(t,e){var n=Math.sqrt(.8908130915292852*e),r=h_*n,i=f_*n;t.moveTo(0,-n),t.lineTo(r,i);for(var a=1;a<5;++a){var o=kx*a/5,s=Math.cos(o),c=Math.sin(o);t.lineTo(c*n,-s*n),t.lineTo(s*r-c*i,c*r+s*i)}t.closePath()}},p_={draw:function(t,e){var n=Math.sqrt(e),r=-n/2;t.rect(r,r,n,n)}},y_=Math.sqrt(3),g_={draw:function(t,e){var n=-Math.sqrt(e/(3*y_));t.moveTo(0,2*n),t.lineTo(-y_*n,-n),t.lineTo(y_*n,-n),t.closePath()}},v_=Math.sqrt(3)/2,m_=1/Math.sqrt(12),b_=3*(m_/2+1),x_={draw:function(t,e){var n=Math.sqrt(e/b_),r=n/2,i=n*m_,a=r,o=n*m_+n,s=-a,c=o;t.moveTo(r,i),t.lineTo(a,o),t.lineTo(s,c),t.lineTo(-.5*r-v_*i,v_*r+-.5*i),t.lineTo(-.5*a-v_*o,v_*a+-.5*o),t.lineTo(-.5*s-v_*c,v_*s+-.5*c),t.lineTo(-.5*r+v_*i,-.5*i-v_*r),t.lineTo(-.5*a+v_*o,-.5*o-v_*a),t.lineTo(-.5*s+v_*c,-.5*c-v_*s),t.closePath()}},__=[a_,o_,u_,p_,d_,g_,x_],k_=function(){var t=fx(a_),e=fx(64),n=null;function r(){var r;if(n||(n=r=Ui()),t.apply(this,arguments).draw(n,+e.apply(this,arguments)),r)return n=null,r+""||null}return r.type=function(e){return arguments.length?(t="function"==typeof e?e:fx(e),r):t},r.size=function(t){return arguments.length?(e="function"==typeof t?t:fx(+t),r):e},r.context=function(t){return arguments.length?(n=null==t?null:t,r):n},r},w_=function(){};function E_(t,e,n){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+e)/6,(t._y0+4*t._y1+n)/6)}function T_(t){this._context=t}T_.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){switch(this._point){case 3:E_(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3,this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:E_(this,t,e)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e}};var C_=function(t){return new T_(t)};function S_(t){this._context=t}S_.prototype={areaStart:w_,areaEnd:w_,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x2,this._y2),this._context.closePath();break;case 2:this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3),this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3),this._context.closePath();break;case 3:this.point(this._x2,this._y2),this.point(this._x3,this._y3),this.point(this._x4,this._y4)}},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._x2=t,this._y2=e;break;case 1:this._point=2,this._x3=t,this._y3=e;break;case 2:this._point=3,this._x4=t,this._y4=e,this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+e)/6);break;default:E_(this,t,e)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e}};var A_=function(t){return new S_(t)};function M_(t){this._context=t}M_.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var n=(this._x0+4*this._x1+t)/6,r=(this._y0+4*this._y1+e)/6;this._line?this._context.lineTo(n,r):this._context.moveTo(n,r);break;case 3:this._point=4;default:E_(this,t,e)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=e}};var O_=function(t){return new M_(t)};function D_(t,e){this._basis=new T_(t),this._beta=e}D_.prototype={lineStart:function(){this._x=[],this._y=[],this._basis.lineStart()},lineEnd:function(){var t=this._x,e=this._y,n=t.length-1;if(n>0)for(var r,i=t[0],a=e[0],o=t[n]-i,s=e[n]-a,c=-1;++c<=n;)r=c/n,this._basis.point(this._beta*t[c]+(1-this._beta)*(i+r*o),this._beta*e[c]+(1-this._beta)*(a+r*s));this._x=this._y=null,this._basis.lineEnd()},point:function(t,e){this._x.push(+t),this._y.push(+e)}};var N_=function t(e){function n(t){return 1===e?new T_(t):new D_(t,e)}return n.beta=function(e){return t(+e)},n}(.85);function B_(t,e,n){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-e),t._y2+t._k*(t._y1-n),t._x2,t._y2)}function L_(t,e){this._context=t,this._k=(1-e)/6}L_.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:B_(this,this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2,this._x1=t,this._y1=e;break;case 2:this._point=3;default:B_(this,t,e)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e}};var P_=function t(e){function n(t){return new L_(t,e)}return n.tension=function(e){return t(+e)},n}(0);function I_(t,e){this._context=t,this._k=(1-e)/6}I_.prototype={areaStart:w_,areaEnd:w_,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._x3=t,this._y3=e;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=e);break;case 2:this._point=3,this._x5=t,this._y5=e;break;default:B_(this,t,e)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e}};var F_=function t(e){function n(t){return new I_(t,e)}return n.tension=function(e){return t(+e)},n}(0);function j_(t,e){this._context=t,this._k=(1-e)/6}j_.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:B_(this,t,e)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e}};var R_=function t(e){function n(t){return new j_(t,e)}return n.tension=function(e){return t(+e)},n}(0);function Y_(t,e,n){var r=t._x1,i=t._y1,a=t._x2,o=t._y2;if(t._l01_a>1e-12){var s=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,c=3*t._l01_a*(t._l01_a+t._l12_a);r=(r*s-t._x0*t._l12_2a+t._x2*t._l01_2a)/c,i=(i*s-t._y0*t._l12_2a+t._y2*t._l01_2a)/c}if(t._l23_a>1e-12){var u=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,l=3*t._l23_a*(t._l23_a+t._l12_a);a=(a*u+t._x1*t._l23_2a-e*t._l12_2a)/l,o=(o*u+t._y1*t._l23_2a-n*t._l12_2a)/l}t._context.bezierCurveTo(r,i,a,o,t._x2,t._y2)}function z_(t,e){this._context=t,this._alpha=e}z_.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:this.point(this._x2,this._y2)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){if(t=+t,e=+e,this._point){var n=this._x2-t,r=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(n*n+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;break;case 2:this._point=3;default:Y_(this,t,e)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e}};var U_=function t(e){function n(t){return e?new z_(t,e):new L_(t,0)}return n.alpha=function(e){return t(+e)},n}(.5);function $_(t,e){this._context=t,this._alpha=e}$_.prototype={areaStart:w_,areaEnd:w_,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,e){if(t=+t,e=+e,this._point){var n=this._x2-t,r=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(n*n+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._x3=t,this._y3=e;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=e);break;case 2:this._point=3,this._x5=t,this._y5=e;break;default:Y_(this,t,e)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e}};var W_=function t(e){function n(t){return e?new $_(t,e):new I_(t,0)}return n.alpha=function(e){return t(+e)},n}(.5);function H_(t,e){this._context=t,this._alpha=e}H_.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,e){if(t=+t,e=+e,this._point){var n=this._x2-t,r=this._y2-e;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(n*n+r*r,this._alpha))}switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Y_(this,t,e)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=e}};var V_=function t(e){function n(t){return e?new H_(t,e):new j_(t,0)}return n.alpha=function(e){return t(+e)},n}(.5);function G_(t){this._context=t}G_.prototype={areaStart:w_,areaEnd:w_,lineStart:function(){this._point=0},lineEnd:function(){this._point&&this._context.closePath()},point:function(t,e){t=+t,e=+e,this._point?this._context.lineTo(t,e):(this._point=1,this._context.moveTo(t,e))}};var q_=function(t){return new G_(t)};function X_(t){return t<0?-1:1}function Z_(t,e,n){var r=t._x1-t._x0,i=e-t._x1,a=(t._y1-t._y0)/(r||i<0&&-0),o=(n-t._y1)/(i||r<0&&-0),s=(a*i+o*r)/(r+i);return(X_(a)+X_(o))*Math.min(Math.abs(a),Math.abs(o),.5*Math.abs(s))||0}function J_(t,e){var n=t._x1-t._x0;return n?(3*(t._y1-t._y0)/n-e)/2:e}function K_(t,e,n){var r=t._x0,i=t._y0,a=t._x1,o=t._y1,s=(a-r)/3;t._context.bezierCurveTo(r+s,i+s*e,a-s,o-s*n,a,o)}function Q_(t){this._context=t}function tk(t){this._context=new ek(t)}function ek(t){this._context=t}function nk(t){return new Q_(t)}function rk(t){return new tk(t)}function ik(t){this._context=t}function ak(t){var e,n,r=t.length-1,i=new Array(r),a=new Array(r),o=new Array(r);for(i[0]=0,a[0]=2,o[0]=t[0]+2*t[1],e=1;e=0;--e)i[e]=(o[e]-i[e+1])/a[e];for(a[r-1]=(t[r]+i[r-1])/2,e=0;e=0&&(this._t=1-this._t,this._line=1-this._line)},point:function(t,e){switch(t=+t,e=+e,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,e):this._context.moveTo(t,e);break;case 1:this._point=2;default:if(this._t<=0)this._context.lineTo(this._x,e),this._context.lineTo(t,e);else{var n=this._x*(1-this._t)+t*this._t;this._context.lineTo(n,this._y),this._context.lineTo(n,e)}}this._x=t,this._y=e}};var ck=function(t){return new sk(t,.5)};function uk(t){return new sk(t,0)}function lk(t){return new sk(t,1)}var hk=function(t,e){if((i=t.length)>1)for(var n,r,i,a=1,o=t[e[0]],s=o.length;a=0;)n[e]=e;return n};function dk(t,e){return t[e]}var pk=function(){var t=fx([]),e=fk,n=hk,r=dk;function i(i){var a,o,s=t.apply(this,arguments),c=i.length,u=s.length,l=new Array(u);for(a=0;a0){for(var n,r,i,a=0,o=t[0].length;a0)for(var n,r,i,a,o,s,c=0,u=t[e[0]].length;c0?(r[0]=a,r[1]=a+=i):i<0?(r[1]=o,r[0]=o+=i):(r[0]=0,r[1]=i)},vk=function(t,e){if((n=t.length)>0){for(var n,r=0,i=t[e[0]],a=i.length;r0&&(r=(n=t[e[0]]).length)>0){for(var n,r,i,a=0,o=1;oa&&(a=e,r=n);return r}var _k=function(t){var e=t.map(kk);return fk(t).sort((function(t,n){return e[t]-e[n]}))};function kk(t){for(var e,n=0,r=-1,i=t.length;++r0)){if(a/=f,f<0){if(a0){if(a>h)return;a>l&&(l=a)}if(a=r-c,f||!(a<0)){if(a/=f,f<0){if(a>h)return;a>l&&(l=a)}else if(f>0){if(a0)){if(a/=d,d<0){if(a0){if(a>h)return;a>l&&(l=a)}if(a=i-u,d||!(a<0)){if(a/=d,d<0){if(a>h)return;a>l&&(l=a)}else if(d>0){if(a0||h<1)||(l>0&&(t[0]=[c+l*f,u+l*d]),h<1&&(t[1]=[c+h*f,u+h*d]),!0)}}}}}function Uk(t,e,n,r,i){var a=t[1];if(a)return!0;var o,s,c=t[0],u=t.left,l=t.right,h=u[0],f=u[1],d=l[0],p=l[1],y=(h+d)/2,g=(f+p)/2;if(p===f){if(y=r)return;if(h>d){if(c){if(c[1]>=i)return}else c=[y,n];a=[y,i]}else{if(c){if(c[1]1)if(h>d){if(c){if(c[1]>=i)return}else c=[(n-s)/o,n];a=[(i-s)/o,i]}else{if(c){if(c[1]=r)return}else c=[e,o*e+s];a=[r,o*r+s]}else{if(c){if(c[0]=-lw)){var d=c*c+u*u,p=l*l+h*h,y=(h*d-u*p)/f,g=(c*p-l*d)/f,v=Gk.pop()||new qk;v.arc=t,v.site=i,v.x=y+o,v.y=(v.cy=g+s)+Math.sqrt(y*y+g*g),t.circle=v;for(var m=null,b=sw._;b;)if(v.yuw)s=s.L;else{if(!((i=a-iw(s,o))>uw)){r>-uw?(e=s.P,n=s):i>-uw?(e=s,n=s.N):e=n=s;break}if(!s.R){e=s;break}s=s.R}!function(t){ow[t.index]={site:t,halfedges:[]}}(t);var c=Qk(t);if(aw.insert(e,c),e||n){if(e===n)return Zk(e),n=Qk(e.site),aw.insert(c,n),c.edge=n.edge=jk(e.site,c.site),Xk(e),void Xk(n);if(n){Zk(e),Zk(n);var u=e.site,l=u[0],h=u[1],f=t[0]-l,d=t[1]-h,p=n.site,y=p[0]-l,g=p[1]-h,v=2*(f*g-d*y),m=f*f+d*d,b=y*y+g*g,x=[(g*m-d*b)/v+l,(f*b-y*m)/v+h];Yk(n.edge,u,p,x),c.edge=jk(u,t,null,x),n.edge=jk(t,p,null,x),Xk(e),Xk(n)}else c.edge=jk(e.site,c.site)}}function rw(t,e){var n=t.site,r=n[0],i=n[1],a=i-e;if(!a)return r;var o=t.P;if(!o)return-1/0;var s=(n=o.site)[0],c=n[1],u=c-e;if(!u)return s;var l=s-r,h=1/a-1/u,f=l/u;return h?(-f+Math.sqrt(f*f-2*h*(l*l/(-2*u)-c+u/2+i-a/2)))/h+r:(r+s)/2}function iw(t,e){var n=t.N;if(n)return rw(n,e);var r=t.site;return r[1]===e?r[0]:1/0}var aw,ow,sw,cw,uw=1e-6,lw=1e-12;function hw(t,e){return e[1]-t[1]||e[0]-t[0]}function fw(t,e){var n,r,i,a=t.sort(hw).pop();for(cw=[],ow=new Array(t.length),aw=new Fk,sw=new Fk;;)if(i=Vk,a&&(!i||a[1]uw||Math.abs(i[0][1]-i[1][1])>uw)||delete cw[a]}(o,s,c,u),function(t,e,n,r){var i,a,o,s,c,u,l,h,f,d,p,y,g=ow.length,v=!0;for(i=0;iuw||Math.abs(y-f)>uw)&&(c.splice(s,0,cw.push(Rk(o,d,Math.abs(p-t)uw?[t,Math.abs(h-t)uw?[Math.abs(f-r)uw?[n,Math.abs(h-n)uw?[Math.abs(f-e)=s)return null;var c=t-i.site[0],u=e-i.site[1],l=c*c+u*u;do{i=a.cells[r=o],o=null,i.halfedges.forEach((function(n){var r=a.edges[n],s=r.left;if(s!==i.site&&s||(s=r.right)){var c=t-s[0],u=e-s[1],h=c*c+u*u;hr?(r+i)/2:Math.min(0,r)||Math.max(0,i),o>a?(a+o)/2:Math.min(0,a)||Math.max(0,o))}var Sw=function(){var t,e,n=_w,r=kw,i=Cw,a=Ew,o=Tw,s=[0,1/0],c=[[-1/0,-1/0],[1/0,1/0]],u=250,l=fp,h=lt("start","zoom","end"),f=0;function d(t){t.property("__zoom",ww).on("wheel.zoom",x).on("mousedown.zoom",_).on("dblclick.zoom",k).filter(o).on("touchstart.zoom",w).on("touchmove.zoom",E).on("touchend.zoom touchcancel.zoom",T).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function p(t,e){return(e=Math.max(s[0],Math.min(s[1],e)))===t.k?t:new gw(e,t.x,t.y)}function y(t,e,n){var r=e[0]-n[0]*t.k,i=e[1]-n[1]*t.k;return r===t.x&&i===t.y?t:new gw(t.k,r,i)}function g(t){return[(+t[0][0]+ +t[1][0])/2,(+t[0][1]+ +t[1][1])/2]}function v(t,e,n){t.on("start.zoom",(function(){m(this,arguments).start()})).on("interrupt.zoom end.zoom",(function(){m(this,arguments).end()})).tween("zoom",(function(){var t=this,i=arguments,a=m(t,i),o=r.apply(t,i),s=null==n?g(o):"function"==typeof n?n.apply(t,i):n,c=Math.max(o[1][0]-o[0][0],o[1][1]-o[0][1]),u=t.__zoom,h="function"==typeof e?e.apply(t,i):e,f=l(u.invert(s).concat(c/u.k),h.invert(s).concat(c/h.k));return function(t){if(1===t)t=h;else{var e=f(t),n=c/e[2];t=new gw(n,s[0]-e[0]*n,s[1]-e[1]*n)}a.zoom(null,t)}}))}function m(t,e,n){return!n&&t.__zooming||new b(t,e)}function b(t,e){this.that=t,this.args=e,this.active=0,this.extent=r.apply(t,e),this.taps=0}function x(){if(n.apply(this,arguments)){var t=m(this,arguments),e=this.__zoom,r=Math.max(s[0],Math.min(s[1],e.k*Math.pow(2,a.apply(this,arguments)))),o=Nn(this);if(t.wheel)t.mouse[0][0]===o[0]&&t.mouse[0][1]===o[1]||(t.mouse[1]=e.invert(t.mouse[0]=o)),clearTimeout(t.wheel);else{if(e.k===r)return;t.mouse=[o,e.invert(o)],or(this),t.start()}xw(),t.wheel=setTimeout(u,150),t.zoom("mouse",i(y(p(e,r),t.mouse[0],t.mouse[1]),t.extent,c))}function u(){t.wheel=null,t.end()}}function _(){if(!e&&n.apply(this,arguments)){var t=m(this,arguments,!0),r=ke(ce.view).on("mousemove.zoom",u,!0).on("mouseup.zoom",l,!0),a=Nn(this),o=ce.clientX,s=ce.clientY;Te(ce.view),bw(),t.mouse=[a,this.__zoom.invert(a)],or(this),t.start()}function u(){if(xw(),!t.moved){var e=ce.clientX-o,n=ce.clientY-s;t.moved=e*e+n*n>f}t.zoom("mouse",i(y(t.that.__zoom,t.mouse[0]=Nn(t.that),t.mouse[1]),t.extent,c))}function l(){r.on("mousemove.zoom mouseup.zoom",null),Ce(ce.view,t.moved),xw(),t.end()}}function k(){if(n.apply(this,arguments)){var t=this.__zoom,e=Nn(this),a=t.invert(e),o=t.k*(ce.shiftKey?.5:2),s=i(y(p(t,o),e,a),r.apply(this,arguments),c);xw(),u>0?ke(this).transition().duration(u).call(v,s,e):ke(this).call(d.transform,s)}}function w(){if(n.apply(this,arguments)){var e,r,i,a,o=ce.touches,s=o.length,c=m(this,arguments,ce.changedTouches.length===s);for(bw(),r=0;rh&&A.push("'"+this.terminals_[T]+"'");O=p.showPosition?"Parse error on line "+(c+1)+":\n"+p.showPosition()+"\nExpecting "+A.join(", ")+", got '"+(this.terminals_[x]||x)+"'":"Parse error on line "+(c+1)+": Unexpected "+(x==f?"end of input":"'"+(this.terminals_[x]||x)+"'"),this.parseError(O,{text:p.match,token:this.terminals_[x]||x,line:p.yylineno,loc:v,expected:A})}if(w[0]instanceof Array&&w.length>1)throw new Error("Parse Error: multiple actions possible at state: "+k+", token: "+x);switch(w[0]){case 1:n.push(x),i.push(p.yytext),a.push(p.yylloc),n.push(w[1]),x=null,_?(x=_,_=null):(u=p.yyleng,s=p.yytext,c=p.yylineno,v=p.yylloc,l>0&&l--);break;case 2:if(C=this.productions_[w[1]][1],M.$=i[i.length-C],M._$={first_line:a[a.length-(C||1)].first_line,last_line:a[a.length-1].last_line,first_column:a[a.length-(C||1)].first_column,last_column:a[a.length-1].last_column},m&&(M._$.range=[a[a.length-(C||1)].range[0],a[a.length-1].range[1]]),void 0!==(E=this.performAction.apply(M,[s,u,c,y.yy,w[1],i,a].concat(d))))return E;C&&(n=n.slice(0,-1*C*2),i=i.slice(0,-1*C),a=a.slice(0,-1*C)),n.push(this.productions_[w[1]][0]),i.push(M.$),a.push(M._$),S=o[n[n.length-2]][n[n.length-1]],n.push(S);break;case 3:return!0}}return!0}},M={EOF:1,parseError:function(t,e){if(!this.yy.parser)throw new Error(t);this.yy.parser.parseError(t,e)},setInput:function(t,e){return this.yy=e||this.yy||{},this._input=t,this._more=this._backtrack=this.done=!1,this.yylineno=this.yyleng=0,this.yytext=this.matched=this.match="",this.conditionStack=["INITIAL"],this.yylloc={first_line:1,first_column:0,last_line:1,last_column:0},this.options.ranges&&(this.yylloc.range=[0,0]),this.offset=0,this},input:function(){var t=this._input[0];return this.yytext+=t,this.yyleng++,this.offset++,this.match+=t,this.matched+=t,t.match(/(?:\r\n?|\n).*/g)?(this.yylineno++,this.yylloc.last_line++):this.yylloc.last_column++,this.options.ranges&&this.yylloc.range[1]++,this._input=this._input.slice(1),t},unput:function(t){var e=t.length,n=t.split(/(?:\r\n?|\n)/g);this._input=t+this._input,this.yytext=this.yytext.substr(0,this.yytext.length-e),this.offset-=e;var r=this.match.split(/(?:\r\n?|\n)/g);this.match=this.match.substr(0,this.match.length-1),this.matched=this.matched.substr(0,this.matched.length-1),n.length-1&&(this.yylineno-=n.length-1);var i=this.yylloc.range;return this.yylloc={first_line:this.yylloc.first_line,last_line:this.yylineno+1,first_column:this.yylloc.first_column,last_column:n?(n.length===r.length?this.yylloc.first_column:0)+r[r.length-n.length].length-n[0].length:this.yylloc.first_column-e},this.options.ranges&&(this.yylloc.range=[i[0],i[0]+this.yyleng-e]),this.yyleng=this.yytext.length,this},more:function(){return this._more=!0,this},reject:function(){return this.options.backtrack_lexer?(this._backtrack=!0,this):this.parseError("Lexical error on line "+(this.yylineno+1)+". You can only invoke reject() in the lexer when the lexer is of the backtracking persuasion (options.backtrack_lexer = true).\n"+this.showPosition(),{text:"",token:null,line:this.yylineno})},less:function(t){this.unput(this.match.slice(t))},pastInput:function(){var t=this.matched.substr(0,this.matched.length-this.match.length);return(t.length>20?"...":"")+t.substr(-20).replace(/\n/g,"")},upcomingInput:function(){var t=this.match;return t.length<20&&(t+=this._input.substr(0,20-t.length)),(t.substr(0,20)+(t.length>20?"...":"")).replace(/\n/g,"")},showPosition:function(){var t=this.pastInput(),e=new Array(t.length+1).join("-");return t+this.upcomingInput()+"\n"+e+"^"},test_match:function(t,e){var n,r,i;if(this.options.backtrack_lexer&&(i={yylineno:this.yylineno,yylloc:{first_line:this.yylloc.first_line,last_line:this.last_line,first_column:this.yylloc.first_column,last_column:this.yylloc.last_column},yytext:this.yytext,match:this.match,matches:this.matches,matched:this.matched,yyleng:this.yyleng,offset:this.offset,_more:this._more,_input:this._input,yy:this.yy,conditionStack:this.conditionStack.slice(0),done:this.done},this.options.ranges&&(i.yylloc.range=this.yylloc.range.slice(0))),(r=t[0].match(/(?:\r\n?|\n).*/g))&&(this.yylineno+=r.length),this.yylloc={first_line:this.yylloc.last_line,last_line:this.yylineno+1,first_column:this.yylloc.last_column,last_column:r?r[r.length-1].length-r[r.length-1].match(/\r?\n?/)[0].length:this.yylloc.last_column+t[0].length},this.yytext+=t[0],this.match+=t[0],this.matches=t,this.yyleng=this.yytext.length,this.options.ranges&&(this.yylloc.range=[this.offset,this.offset+=this.yyleng]),this._more=!1,this._backtrack=!1,this._input=this._input.slice(t[0].length),this.matched+=t[0],n=this.performAction.call(this,this.yy,this,e,this.conditionStack[this.conditionStack.length-1]),this.done&&this._input&&(this.done=!1),n)return n;if(this._backtrack){for(var a in i)this[a]=i[a];return!1}return!1},next:function(){if(this.done)return this.EOF;var t,e,n,r;this._input||(this.done=!0),this._more||(this.yytext="",this.match="");for(var i=this._currentRules(),a=0;ae[0].length)){if(e=n,r=a,this.options.backtrack_lexer){if(!1!==(t=this.test_match(n,i[a])))return t;if(this._backtrack){e=!1;continue}return!1}if(!this.options.flex)break}return e?!1!==(t=this.test_match(e,i[r]))&&t:""===this._input?this.EOF:this.parseError("Lexical error on line "+(this.yylineno+1)+". Unrecognized text.\n"+this.showPosition(),{text:"",token:null,line:this.yylineno})},lex:function(){var t=this.next();return t||this.lex()},begin:function(t){this.conditionStack.push(t)},popState:function(){return this.conditionStack.length-1>0?this.conditionStack.pop():this.conditionStack[0]},_currentRules:function(){return this.conditionStack.length&&this.conditionStack[this.conditionStack.length-1]?this.conditions[this.conditionStack[this.conditionStack.length-1]].rules:this.conditions.INITIAL.rules},topState:function(t){return(t=this.conditionStack.length-1-Math.abs(t||0))>=0?this.conditionStack[t]:"INITIAL"},pushState:function(t){this.begin(t)},stateStackSize:function(){return this.conditionStack.length},options:{"case-insensitive":!0},performAction:function(t,e,n,r){switch(n){case 0:return this.begin("open_directive"),58;case 1:return this.begin("type_directive"),59;case 2:return this.popState(),this.begin("arg_directive"),14;case 3:return this.popState(),this.popState(),61;case 4:return 60;case 5:return 5;case 6:case 7:case 8:case 9:case 10:break;case 11:return this.begin("ID"),16;case 12:return e.yytext=e.yytext.trim(),this.begin("ALIAS"),48;case 13:return this.popState(),this.popState(),this.begin("LINE"),18;case 14:return this.popState(),this.popState(),5;case 15:return this.begin("LINE"),27;case 16:return this.begin("LINE"),29;case 17:return this.begin("LINE"),30;case 18:return this.begin("LINE"),31;case 19:return this.begin("LINE"),36;case 20:return this.begin("LINE"),33;case 21:return this.begin("LINE"),35;case 22:return this.popState(),19;case 23:return 28;case 24:return 43;case 25:return 44;case 26:return 39;case 27:return 37;case 28:return this.begin("ID"),22;case 29:return this.begin("ID"),23;case 30:return 25;case 31:return 7;case 32:return 21;case 33:return 42;case 34:return 5;case 35:return e.yytext=e.yytext.trim(),48;case 36:return 51;case 37:return 52;case 38:return 49;case 39:return 50;case 40:return 53;case 41:return 54;case 42:return 55;case 43:return 56;case 44:return 57;case 45:return 46;case 46:return 47;case 47:return 5;case 48:return"INVALID"}},rules:[/^(?:%%\{)/i,/^(?:((?:(?!\}%%)[^:.])*))/i,/^(?::)/i,/^(?:\}%%)/i,/^(?:((?:(?!\}%%).|\n)*))/i,/^(?:[\n]+)/i,/^(?:\s+)/i,/^(?:((?!\n)\s)+)/i,/^(?:#[^\n]*)/i,/^(?:%(?!\{)[^\n]*)/i,/^(?:[^\}]%%[^\n]*)/i,/^(?:participant\b)/i,/^(?:[^\->:\n,;]+?(?=((?!\n)\s)+as(?!\n)\s|[#\n;]|$))/i,/^(?:as\b)/i,/^(?:(?:))/i,/^(?:loop\b)/i,/^(?:rect\b)/i,/^(?:opt\b)/i,/^(?:alt\b)/i,/^(?:else\b)/i,/^(?:par\b)/i,/^(?:and\b)/i,/^(?:(?:[:]?(?:no)?wrap)?[^#\n;]*)/i,/^(?:end\b)/i,/^(?:left of\b)/i,/^(?:right of\b)/i,/^(?:over\b)/i,/^(?:note\b)/i,/^(?:activate\b)/i,/^(?:deactivate\b)/i,/^(?:title\b)/i,/^(?:sequenceDiagram\b)/i,/^(?:autonumber\b)/i,/^(?:,)/i,/^(?:;)/i,/^(?:[^\+\->:\n,;]+((?!(-x|--x|-\)|--\)))[\-]*[^\+\->:\n,;]+)*)/i,/^(?:->>)/i,/^(?:-->>)/i,/^(?:->)/i,/^(?:-->)/i,/^(?:-[x])/i,/^(?:--[x])/i,/^(?:-[\)])/i,/^(?:--[\)])/i,/^(?::(?:(?:no)?wrap)?[^#\n;]+)/i,/^(?:\+)/i,/^(?:-)/i,/^(?:$)/i,/^(?:.)/i],conditions:{open_directive:{rules:[1,8],inclusive:!1},type_directive:{rules:[2,3,8],inclusive:!1},arg_directive:{rules:[3,4,8],inclusive:!1},ID:{rules:[7,8,12],inclusive:!1},ALIAS:{rules:[7,8,13,14],inclusive:!1},LINE:{rules:[7,8,22],inclusive:!1},INITIAL:{rules:[0,5,6,8,9,10,11,15,16,17,18,19,20,21,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48],inclusive:!0}}};function O(){this.yy={}}return A.lexer=M,O.prototype=A,A.Parser=O,new O}();e.parser=i,e.Parser=i.Parser,e.parse=function(){return i.parse.apply(i,arguments)},e.main=function(r){r[1]||(console.log("Usage: "+r[0]+" FILE"),t.exit(1));var i=n(19).readFileSync(n(20).normalize(r[1]),"utf8");return e.parser.parse(i)},n.c[n.s]===r&&e.main(t.argv.slice(1))}).call(this,n(14),n(7)(t))},function(t,e,n){var r=n(198);t.exports={Graph:r.Graph,json:n(301),alg:n(302),version:r.version}},function(t,e,n){var r;try{r={cloneDeep:n(313),constant:n(86),defaults:n(154),each:n(87),filter:n(128),find:n(314),flatten:n(156),forEach:n(126),forIn:n(319),has:n(93),isUndefined:n(139),last:n(320),map:n(140),mapValues:n(321),max:n(322),merge:n(324),min:n(329),minBy:n(330),now:n(331),pick:n(161),range:n(162),reduce:n(142),sortBy:n(338),uniqueId:n(163),values:n(147),zipObject:n(343)}}catch(t){}r||(r=window._),t.exports=r},function(t,e){var n=Array.isArray;t.exports=n},function(t,e,n){ diff --git a/docs/api/access.rst b/docs/api/access.rst new file mode 100644 index 0000000000..9379a27cb7 --- /dev/null +++ b/docs/api/access.rst @@ -0,0 +1,529 @@ +.. _sec-api-access: + +************** +Access control +************** + +.. contents:: + +.. _sec-api-access-permissions: + +Permissions +=========== + +.. _sec-api-access-permissions-list: + +List all permissions +-------------------- + +.. http:get:: /api/access/permissions + + Retrieves all permissions available in the system. + + Will return a :http:statuscode:`200` with a :ref:`permission list ` + as body. + + :status 200: No error + +.. _sec-api-access-groups: + +Groups +====== + +.. _sec-api-access-groups-list: + +Get group list +-------------- + +.. http:get:: /api/access/groups + + Retrieves all groups registered in the system. + + Will return a :http:statuscode:`200` with a :ref:`group list ` + as body. + + Requires the ``SETTINGS`` permission. + + :status 200: No error + +.. _sec-api-access-groups-add: + +Add a new group +--------------- + +.. http:post:: /api/access/groups + + Adds a new group to the system. + + Expects a :ref:`group registration request ` as request body. + + Will return a :ref:`group list response ` on success. + + Requires the ``SETTINGS`` permission. + + :json key: The group's identifier + :json name: The user's name + :json description: A human readable description of the group + :json permissions: The permissions to assign to the group + :json subgroups: Subgroups assigned to the group + :json default: Whether the group should be assigned to new users by default or not + :status 200: No error + :status 400: If any of the mandatory fields is missing or the request is otherwise + invalid + :status 409: A group with the provided key does already exist + +.. _sec-api-access-groups-retrieve: + +Retrieve a group +---------------- + +.. http:get:: /api/access/groups/(string:key) + + Retrieves an individual group record. + + Will return a :http:statuscode:`200` with a :ref:`group record ` + as body. + + Requires the ``SETTINGS`` permission. + + :status 200: No error + +.. _sec-api-access-groups-modify: + +Update a group +-------------- + +.. http:put:: /api/access/groups/(string:key) + + Updates an existing group. + + Expects a :ref:`group update request ` as request body. + + Will return a :ref:`group list response ` on success. + + Requires the ``SETTINGS`` permission. + + :json description: A human readable description of the group + :json permissions: The permissions to assign to the group + :json subgroups: Subgroups assigned to the group + :json default: Whether the group should be assigned to new users by default or not + :status 200: No error + :status 400: If any of the mandatory fields is missing or the request is otherwise + invalid + +.. _sec-api-access-groups-delete: + +Delete a group +-------------- + +.. http:delete:: /api/access/groups/(string:key) + + Deletes a group. + + Will return a :ref:`group list response ` on success. + + Requires the ``SETTINGS`` permission. + + :status 200: No error + +.. _sec-api-access-users: + +Users +===== + +.. _sec-api-access-users-list: + +Retrieve a list of users +======================== + +.. http:get:: /api/access/users + + Retrieves a list of all registered users in OctoPrint. + + Will return a :http:statuscode:`200` with a :ref:`user list response ` + as body. + + Requires the ``SETTINGS`` permission. + + :status 200: No error + +.. _sec-api-access-users-retrieve: + +Retrieve a user +--------------- + +.. http:get:: /api/access/users/(string:username) + + Retrieves information about a user. + + Will return a :http:statuscode:`200` with a :ref:`user record ` + as body. + + Requires either the ``SETTINGS`` permission or to be logged in as the user. + + :param username: Name of the user which to retrieve + :status 200: No error + :status 404: Unknown user + +.. _sec-api-access-users-add: + +Add a new user +-------------- + +.. http:post:: /api/access/users + + Adds a user to OctoPrint. + + Expects a :ref:`user registration request ` + as request body. + + Returns a list of registered users on success, see :ref:`Retrieve a list of users `. + + Requires the ``SETTINGS`` permission. + + :json name: The user's name + :json password: The user's password + :json active: Whether to activate the account (true) or not (false) + :json admin: Whether to give the account admin rights (true) or not (false) + :status 200: No error + :status 400: If any of the mandatory fields is missing or the request is otherwise + invalid + :status 409: A user with the provided name does already exist + +.. _sec-api-access-users-modify: + +Update a user +------------- + +.. http:put:: /api/access/users/(string:username) + + Updates a user record. + + Expects a :ref:`user update request ` + as request body. + + Returns a list of registered users on success, see :ref:`Retrieve a list of users `. + + Requires the ``SETTINGS`` permission. + + :param username: Name of the user to update + :json admin: Whether to mark the user as admin (true) or not (false), can be left out (no change) + :json active: Whether to mark the account as activated (true) or deactivated (false), can be left out (no change) + :status 200: No error + :status 404: Unknown user + +.. _sec-api-access-users-delete: + +Delete a user +------------- + +.. http:delete:: /api/access/users/(string:username) + + Delete a user record. + + Returns a list of registered users on success, see :ref:`Retrieve a list of users `. + + Requires the ``SETTINGS`` permission. + + :param username: Name of the user to delete + :status 200: No error + :status 404: Unknown user + +.. _sec-api-access-users-password: + +Change a user's password +------------------------ + +.. http:put:: /api/access/users/(string:username)/password + + Changes the password of a user. + + Expects a JSON object with a single property ``password`` as request body. + + Requires the ``SETTINGS`` permission or to be logged in as the user. + + :param username: Name of the user to change the password for + :json password: The new password to set + :status 200: No error + :status 400: If the request doesn't contain a ``password`` property or the request + is otherwise invalid + :status 403: No admin rights and not logged in as the user + :status 404: The user is unknown + +.. _sec-api-access-users-settings-get: + +Get a user's settings +--------------------- + +.. http:get:: /api/access/users/(string:username)/settings + + Retrieves a user's settings. + + Will return a :http:statuscode:`200` with a JSON object representing the user's + personal settings (if any) as body. + + Requires the ``SETTINGS`` permission or to be logged in as the user. + + :param username: Name of the user to retrieve the settings for + :status 200: No error + :status 403: No admin rights and not logged in as the user + :status 404: The user is unknown + +.. _sec-api-access-users-settings-set: + +Update a user's settings +------------------------ + +.. http:patch:: /api/access/users/(string:username)/settings + + Updates a user's settings. + + Expects a new settings JSON object to merge with the current settings as + request body. + + Requires the ``SETTINGS`` permission or to be logged in as the user. + + :param username: Name of the user to retrieve the settings for + :status 204: No error + :status 403: No admin rights and not logged in as the user + :status 404: The user is unknown + +.. _sec-api-access-users-apikey-generate: + +Regenerate a user's api key +--------------------------- + +.. http:post:: /api/access/users/(string:username)/apikey + + Generates a new API key for the user. + + Does not expect a body. Will return the generated API key as ``apikey`` + property in the JSON object contained in the response body. + + Requires the ``SETTINGS`` permission or to be logged in as the user. + + :param username: Name of the user to retrieve the settings for + :status 200: No error + :status 403: No admin rights and not logged in as the user + :status 404: The user is unknown + +.. _sec-api-access-users-apikey-delete: + +Delete a user's api key +----------------------- + +.. http:delete:: /api/access/users/(string:username)/apikey + + Deletes a user's personal API key. + + Requires the ``SETTINGS`` permission or to be logged in as the user. + + :param username: Name of the user to retrieve the settings for + :status 204: No error + :status 403: No admin rights and not logged in as the user + :status 404: The user is unknown + +.. _sec-api-access-datamodel: + +Data model +========== + +.. _sec-api-access-datamodel-permissions: + +Permissions +----------- + +.. _sec-api-access-datamodel-permissions-list: + +Permission list response +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``permissions`` + - 0..n + - List of :ref:`permission records ` + - The list of permissions + + +.. _sec-api-access-datamodel-groups: + +Groups +------ + +.. _sec-api-access-datamodel-groups-list: + +Group list response +~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``groups`` + - 0..n + - List of :ref:`group records ` + - The list of groups + +.. _sec-api-access-datamodel-groups-addgrouprequest: + +Group registration request +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``key`` + - 1 + - string + - The group's identifier + * - ``name`` + - 1 + - string + - The group's name + * - ``description`` + - 0..1 + - string + - The group's description. Set to empty if not provided. + * - ``permissions`` + - 1..n + - List of string + - A list of identifier's of permissions to assign to the group + * - ``subgroups`` + - 0..n + - List of string + - A list of identifier's of groups to assign to the group as subgroups + * - ``default`` + - 0..1 + - boolean + - Whether to assign the group to new users by default (true) or not (false, default value) + +.. _sec-api-access-datamodel-groups-updategrouprequest: + +Group update request +~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``description`` + - 0..1 + - string + - The group's description. Set to empty if not provided. + * - ``permissions`` + - 1..n + - List of string + - A list of identifier's of permissions to assign to the group + * - ``subgroups`` + - 0..n + - List of string + - A list of identifier's of groups to assign to the group as subgroups + * - ``default`` + - 0..1 + - boolean + - Whether to assign the group to new users by default (true) or not (false, default value) + + +.. _sec-api-access-datamodel-users: + +Users +----- + +.. _sec-api-access-datamodel-users-userlistresponse: + +User list response +~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``users`` + - 0..n + - List of :ref:`user records ` + - The list of users + +.. _sec-api-access-datamodel-users-adduserrequest: + +User registration request +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``name`` + - 1 + - string + - The user's name + * - ``password`` + - 1 + - string + - The user's password + * - ``active`` + - 1 + - bool + - Whether to activate the account (true) or not (false) + * - ``groups`` + - 0..n + - List of string + - A list of identifiers of groups to assign to the user + * - ``permissions`` + - 0..n + - List of string + - A list of identifiers of permissions to assign to the user + +.. _sec-api-access-datamodel-users-updateuserrequest: + +User update request +~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``active`` + - 0..1 + - bool + - If present will set the user's active flag to the provided value. True for + activating the account, false for deactivating it. + * - ``groups`` + - 0..n + - List of string + - A list of identifiers of groups to assign to the user + * - ``permissions`` + - 0..n + - List of string + - A list of identifiers of permissions to assign to the user diff --git a/docs/api/apps.rst b/docs/api/apps.rst deleted file mode 100644 index 46ee73c8cb..0000000000 --- a/docs/api/apps.rst +++ /dev/null @@ -1,170 +0,0 @@ -.. _sec-api-apps: - -**** -Apps -**** - -.. contents:: - -.. _sec-api-apps-sessionkey: - -Session Keys -============ - -OctoPrint offers a special API key type for apps to use, the so called App Session Key. These keys have a time based -validity and are generated by OctoPrint for requesting apps. - -Obtaining those keys is based on a handshake procedure backed by cryptographic signatures using RSA. OctoPrint needs to -be aware of apps and their associated public keys (this can be achieved either via entries in ``config.yaml`` or by -installing app specific plugins which implement the ``AppPlugin`` type). - -Apps can be registered within OctoPrint via ``config.yaml`` by adding them to the ``api`` > ``apps`` section, using the -application's id concatenated with its version as key, with the public key provided as item ``pubkey`` (stripped of the -``BEGIN RSA PUBLIC KEY`` and ``END RSA PUBLIC KEY`` separators and also newlines) and optionally also whether the app is -enabled or not (defaults to enabled, so can be left out if it's not to be set to disabled explicitly). - -Example: - -.. sourcecode:: yaml - - api: - apps: - "com.example.my_octoprint_app:0.9": - pubkey: MEgCQQDYkr5Fv/YXK5ZL1uwRN4A61IagZaYLGqJ5JJGFo8wDrmpAMRqE9kK4+5hIDblC5DzfEr5oP7OA3tRO48Rf5yInAgMBAAE= - enabled: false - "com.example.my_octoprint_app:1.0": - pubkey: MEgCQQCIWfi7Nc8bcnfZJJtA6a4RyMC+sKBlMOb25OVNNB4L2v0TiGO72jVKR4osvb4oztlbRW5GkdiY0T2LJcfDYvkJAgMBAAE= - -In the example, the app ``com.example.my_octoprint_app`` in version 0.9 has been disabled (e.g. due to the key having -leaked) whereas version 1.0 is fully registered with OctoPrint and may verify app session keys. - - -.. _sec-api-apps-sessionkey-workflow: - -Workflow --------- - -Apps perform the handshake by first requesting a temporary key with very limited validity, -then sending a message back to OctoPrint containing their id, version, the temporary key and a signature created with their -private key over these three pieces of data. OctoPrint then tries to verify the signature and if successful unlocks the -key to be used as a fully recognized API key. - -For performing the handshake a special API exists within OctoPrint for which no API key is needed which is described below. - -.. _sec-api-apps-sessionkey-get: - -Obtaining a temporary session key ---------------------------------- - -.. http:get:: /apps/auth - - Retrieve a temporary session key with a minimum validity. It can only be used as a proper API key after having been - :ref:`verified `. - - Returns the temporary session key and the timestamp until it's valid. - - **Example**: - - .. sourcecode:: http - - GET /apps/auth HTTP/1.1 - Host: example.com - - .. sourcecode:: http - - HTTP/1.1 200 OK - Content-Type: application/json - - { - "unverifiedKey": "F43A844750F74AD080FE9F438D47B33C", - "validUntil": 1416220357.011 - } - - :statuscode 200: No error - -.. _sec-api-apps-sessionkey-verify: - -Verifying a temporary session key ---------------------------------- - -.. http:post:: /apps/auth - - Verify a formerly :ref:`retrieved ` temporary session key by providing credentials and a - cryptographic signature over these credentials and the temporary key. - - Returns the now verified session key and the new validity. - - **Example**: - - .. sourcecode:: http - - POST /apps/auth HTTP/1.1 - Host: example.com - Content-Type: application/json - - { - "appid": "com.example.my_octoprint_app", - "appversion": "1.0", - "key": "F43A844750F74AD080FE9F438D47B33C", - "_sig": "LGVCiolQWDc4AVn1DOcWljY0cFQxWF4pldVveUjjmL9JhiL0LnCKBbGwZ/CwKBWswFAxPaxQ0kDusVdOmCUa/w==" - } - - .. sourcecode:: http - - HTTP/1.1 200 OK - Content-Type: application/json - - { - "key": "F43A844750F74AD080FE9F438D47B33C", - "validUntil": 1416227497.011 - } - -.. _sec-api-apps-sessionkey-signature: - -Creating the signature ----------------------- - -The signature is created by concatenating the ``appid``, ``appversion`` and ``key`` fields, separated by a ``:`` (colon), -signing the result with the app's private key using SHA-1 and then BASE64-encoding the result, stripping newlines. - -Example for signature generation using Python and the `Python RSA library `_: - -.. sourcecode:: python - - import base64 - import rsa - - appid = "com.example.my_octoprint_app" - version = "1.0" - unverified_key = "F43A844750F74AD080FE9F438D47B33C" - message_to_sign = appid + ":" + version + ":" + unverified_key - // => "com.example.my_octoprint_app:1.0:F43A844750F74AD080FE9F438D47B33C" - - private_key = rsa.PrivateKey.load_pkcs1("...") - signature = base64.encodestring(rsa.sign(message_to_sign, private_key, "SHA-1")).replace("\n", "") - // => "LGVCiolQWDc4AVn1DOcWljY0cFQxWF4pldVveUjjmL9JhiL0LnCKBbGwZ/CwKBWswFAxPaxQ0kDusVdOmCUa/w==" - - -.. _sec-api-apps-sessionkey-testing: - -Testing your implementation ---------------------------- - -If you want to use app session keys, here is the key pair with which the above examples were created, in order for you -to verify your signature implementation:: - - -----BEGIN RSA PRIVATE KEY----- - MIIBPQIBAAJBAIhZ+Ls1zxtyd9kkm0DprhHIwL6woGUw5vbk5U00Hgva/ROIY7va - NUpHiiy9vijO2VtFbkaR2JjRPYslx8Ni+QkCAwEAAQJARK4lFo+FEcs3yR2iQjEy - p+yaAbNQJ4hZXlVvltLAYICzOM3kyKx53/eKU59NjskLz9q6QxfleymYPWAgl4NW - fQIjAJVH8MjwNcaAquTM9z2OiFi3OC8WgaKOi5W/T+r2+B70wG8CHwDp08dqOZ/u - xcBiy4Wzpcme9bckqoVuS3gWMm+YqgcCIwCMFU07kkY0NyumtzxPdIA4F/7OGSWf - IHqWFEfvasAddHlbAh8A5UgkB3Zf7Bt+7aFSBnlvve6FWm/XDPL12xYztYgrAiIa - W3miN6FjIm+8TDowrk+nyYXG2GZefeY7QXOjYr6tlDn0 - -----END RSA PRIVATE KEY----- - - -----BEGIN RSA PUBLIC KEY----- - MEgCQQCIWfi7Nc8bcnfZJJtA6a4RyMC+sKBlMOb25OVNNB4L2v0TiGO72jVKR4os - vb4oztlbRW5GkdiY0T2LJcfDYvkJAgMBAAE= - -----END RSA PUBLIC KEY----- - diff --git a/docs/api/connection.rst b/docs/api/connection.rst index 721e92cac5..97655db9f8 100644 --- a/docs/api/connection.rst +++ b/docs/api/connection.rst @@ -16,6 +16,8 @@ Get connection settings Retrieve the current connection settings, including information regarding the available baudrates and serial ports and the current connection state. + Requires the ``STATUS`` permission. + **Example** .. sourcecode:: http @@ -59,7 +61,7 @@ Issue a connection command Issue a connection command. Currently available command are: connect - Instructs OctoPrint to connect to the printer. Additional parameters are: + Instructs OctoPrint to connect or, if already connected, reconnect to the printer. Additional parameters are: * ``port``: Optional, specific port to connect to. If not set the current ``portPreference`` will be used, or if no preference is available auto detection will be attempted. @@ -81,7 +83,7 @@ Issue a connection command for the lost acknowledgment should always be properly investigated and removed instead of depending on this "symptom solver". - Requires user rights. + Requires the ``CONNECTION`` permission. **Example Connect Request** diff --git a/docs/api/datamodel.rst b/docs/api/datamodel.rst index 92e32050f5..24a10f904f 100644 --- a/docs/api/datamodel.rst +++ b/docs/api/datamodel.rst @@ -44,6 +44,14 @@ Printer State - 1 - Boolean - ``true`` if the printer is currently printing, ``false`` otherwise + * - ``flags.pausing`` + - 1 + - Boolean + - ``true`` if the printer is currently printing and in the process of pausing, ``false`` otherwise + * - ``flags.cancelling`` + - 1 + - Boolean + - ``true`` if the printer is currently printing and in the process of pausing, ``false`` otherwise * - ``flags.sdReady`` - 1 - Boolean @@ -137,6 +145,31 @@ Temperature offset - Number - Temperature offset for the printer's heated bed. +.. _sec-api-datamodel-printer-resends: + +Resend stats +------------ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``count`` + - 1 + - int + - Number of resend requests received since connecting. + * - ``transmitted`` + - 1 + - int + - Number of transmitted lines since connecting. + * - ``ratio`` + - 1 + - int + - Percentage of resend requests vs transmitted lines. Value between 0 and 100. .. _sec-api-datamodel-jobs: @@ -162,11 +195,11 @@ Job information - The file that is the target of the current print job * - ``estimatedPrintTime`` - 0..1 - - Integer + - Float - The estimated print time for the file, in seconds. * - ``lastPrintTime`` - 0..1 - - Integer + - Float - The print time of the last print of the file, in seconds. * - ``filament`` - 0..1 @@ -174,7 +207,7 @@ Job information - Information regarding the estimated filament usage of the print job * - ``filament.length`` - 0..1 - - Integer + - Float - Length of filament used, in mm * - ``filament.volume`` - 0..1 @@ -210,6 +243,17 @@ Progress information - 1 - Integer - Estimate of time left to print, in seconds + * - ``printTimeLeftOrigin`` + - 1 + - String + - Origin of the current time left estimate. Can currently be either of: + + * ``linear``: based on an linear approximation of the progress in file in bytes vs time + * ``analysis``: based on an analysis of the file + * ``estimate``: calculated estimate after stabilization of linear estimation + * ``average``: based on the average total from past prints of the same model against the same printer profile + * ``mixed-analysis``: mixture of ``estimate`` and ``analysis`` + * ``mixed-average``: mixture of ``estimate`` and ``average`` .. _sec-api-datamodel-files: @@ -255,8 +299,11 @@ File information - Path to type of file in extension tree. E.g. ``["model", "stl"]`` for ``.stl`` files, or ``["machinecode", "gcode"]`` for ``.gcode`` files. ``["folder"]`` for folders. -Additional properties depend on ``type``. For a ``type`` value of ``folder``, see "Folders". For any other value -see "Files". +Additional properties depend on ``type``. +For a ``type`` value of ``folder``, see :ref:`Folders `. +For any other value see :ref:`Files `. + +.. _sec-api-datamodel-files-folders: Folders ''''''' @@ -272,13 +319,14 @@ Folders * - ``children`` - 0..* - Array of :ref:`File information items ` - - Contained children for entries of type ``folder``. Will only include children in subfolders in recursive - listings. Not present in non recursive listings, this might be revisited in the future. + - Contained children for entries of type ``folder``. On non recursive listings only present on first level + sub folders! * - ``size`` - 0..1 - Number - - The size of all files contained in the folder and its subfolders. Not present in non recursive listings, this might - be revisited in the future. + - The size of all files contained in the folder and its subfolders. Not present in non recursive listings! + +.. _sec-api-datamodel-files-files: Files ''''' @@ -317,6 +365,14 @@ Files - 0..1 - :ref:`GCODE analysis information ` - Information from the analysis of the GCODE file, if available. Left out in abridged version. + * - ``prints`` + - 0..1 + - :ref:`Print history information ` + - Information about previous prints of the file. Left out if the file has never been printed. + * - ``statistics`` + - 0..1 + - :ref:`Print statistics information ` + - Statistics about the file, based on the previous print times. Left out if the file has never been printed. .. _sec-api-datamodel-files-fileabridged: @@ -371,20 +427,64 @@ GCODE analysis information - Description * - ``estimatedPrintTime`` - 0..1 - - Integer + - Float - The estimated print time of the file, in seconds * - ``filament`` - 0..1 - Object - The estimated usage of filament - * - ``filament.length`` + * - ``filament.tool{n}.length`` - 0..1 - - Integer + - Float - The length of filament used, in mm - * - ``filament.volume`` + * - ``filament.tool{n}.volume`` - 0..1 - Float - The volume of filament used, in cm³ + * - ``dimensions`` + - 0..1 + - Object + - Information regarding the size of the printed model + * - ``dimensions.depth`` + - 0..1 + - Float + - The depth of the printed model, in mm + * - ``dimensions.height`` + - 0..1 + - Float + - The height of the printed model, in mm + * - ``dimensions.width`` + - 0..1 + - Float + - The width of the printed model, in mm + * - ``printingArea`` + - 0..1 + - Object + - Information regarding the size of the printing area + * - ``printingArea.maxX`` + - 0..1 + - Float + - The maximum X coordinate of the printed model, in mm + * - ``printingArea.maxY`` + - 0..1 + - Float + - The maximum Y coordinate of the printed model, in mm + * - ``printingArea.maxZ`` + - 0..1 + - Float + - The maximum Z coordinate of the printed model, in mm + * - ``printingArea.minX`` + - 0..1 + - Float + - The minimum X coordinate of the printed model, in mm + * - ``printingArea.minY`` + - 0..1 + - Float + - The minimum Y coordinate of the printed model, in mm + * - ``printingArea.minZ`` + - 0..1 + - Float + - The minimum Z coordinate of the printed model, in mm .. _sec-api-datamodel-files-ref: @@ -414,3 +514,228 @@ References - The model from which this file was generated (e.g. an STL, currently not used). Never present for folders. +.. _sec-api-datamodel-files-prints: + +Print History +------------- + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``success`` + - 1 + - Number + - Number of successful prints + * - ``failure`` + - 1 + - Number + - Number of failed prints + * - ``last.date`` + - 1 + - Unix Timestamp + - Last date this file was printed + * - ``last.printTime`` + - 1 + - Float + - Last print time in seconds + * - ``last.success`` + - 1 + - Boolean + - Whether the last print was a success or not + +.. _sec-api-datamodel-files-stats: + +Print Statistics +---------------- + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``averagePrintTime`` + - 1 + - Object + - Object that maps printer profile names to the last print time of the file, in seconds + * - ``lastPrintTime`` + - 1 + - Object + - Object that maps printer profile names to the average print time of the file, in seconds + +.. _sec-api-datamodel-access: + +Access control +============== + +.. _sec-api-datamodel-access-users: + +User record +----------- + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``name`` + - 1 + - string + - The user's name + * - ``active`` + - 1 + - bool + - Whether the user's account is active (true) or not (false) + * - ``user`` + - 1 + - bool + - Whether the user has user rights. Should always be true. Deprecated as of 1.4.0, use the ``users`` group instead. + * - ``admin`` + - 1 + - bool + - Whether the user has admin rights (true) or not (false). Deprecated as of 1.4.0, use the ``admins`` group instead. + * - ``apikey`` + - 0..1 + - string + - The user's personal API key + * - ``settings`` + - 1 + - object + - The user's personal settings, might be an empty object. + * - ``groups`` + - 1..n + - List of string + - Groups assigned to the user + * - ``needs`` + - 1 + - :ref:`Needs object ` + - Effective needs of the user + * - ``permissions`` + - 0..n + - List of :ref:`Permissions ` + - The list of permissions assigned to the user (note: this does not include implicit permissions inherit from groups). + +.. _sec-api-datamodel-access-permissions: + +Permission record +----------------- + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``key`` + - 1 + - string + - The permission's identifier + * - ``name`` + - 1 + - string + - The permission's name + * - ``dangerous`` + - 1 + - boolean + - Whether the permission should be considered dangerous due to a high reponsibility (true) or not (false). + * - ``default_groups`` + - 1 + - List of string + - List of group identifiers for which this permission is enabled by default + * - ``description`` + - 1 + - string + - Human readable description of the permission + * - ``needs`` + - 1 + - :ref:`Needs object ` + - Needs assigned to the permission + +.. _sec-api-datamodel-access-groups: + +Group record +------------ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``key`` + - 1 + - string + - The group's identifier + * - ``name`` + - 1 + - string + - The group's name + * - ``description`` + - 1 + - string + - A human readable description of the group + * - ``permissions`` + - 0..n + - List of :ref:`Permissions ` + - The list of permissions assigned to the group (note: this does not include implicit permissions inherited from + subgroups). + * - ``subgroups`` + - 0..n + - List of :ref:`Groups ` + - Subgroups assigned to the group + * - ``needs`` + - 1 + - :ref:`Needs object ` + - Effective needs of the group + * - ``default`` + - 1 + - boolean + - Whether this is a default group (true) or not (false) + * - ``removable`` + - 1 + - boolean + - Whether this group can be removed (true) or not (false) + * - ``changeable`` + - 1 + - boolean + - Whether this group can be modified (true) or not (false) + * - ``toggleable`` + - 1 + - boolean + - Whether this group can be assigned to users or other groups (true) or not (false) + +.. _sec-api-datamodel-access-needs: + +Needs +----- + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``role`` + - 0..1 + - List of string + - List of ``role`` needs + * - ``group`` + - 0..1 + - List of string + - List of ``group`` needs diff --git a/docs/api/files.rst b/docs/api/files.rst index 188731a40b..3df618d90c 100644 --- a/docs/api/files.rst +++ b/docs/api/files.rst @@ -14,13 +14,18 @@ Retrieve all files .. http:get:: /api/files Retrieve information regarding all files currently available and regarding the disk space still available - locally in the system. + locally in the system. The results are cached for performance reasons. If you + want to override the cache, supply the query parameter ``force`` and set it to ``true``. Note that + while printing a refresh/override of the cache for files stored on the printer's SD card + is disabled due to bandwidth restrictions on the serial interface. By default only returns the files and folders in the root directory. If the query parameter ``recursive`` is provided and set to ``true``, returns all files and folders. Returns a :ref:`Retrieve response `. + Requires the ``FILES_LIST`` permission. + **Example 1**: Fetch only the files and folders from the root folder. @@ -82,8 +87,37 @@ Retrieve all files "path": "folderA", "type": "folder", "typePath": ["folder"], - "children": [], - "size": 1334 + "children": [ + { + "name": "whistle_v2_copy.gcode", + "path": "whistle_v2_copy.gcode", + "type": "machinecode", + "typePath": ["machinecode", "gcode"], + "hash": "...", + "size": 1468987, + "date": 1378847754, + "origin": "local", + "refs": { + "resource": "http://example.com/api/files/local/folderA/whistle_v2_copy.gcode", + "download": "http://example.com/downloads/files/local/folderA/whistle_v2_copy.gcode" + }, + "gcodeAnalysis": { + "estimatedPrintTime": 1188, + "filament": { + "length": 810, + "volume": 5.36 + } + }, + "print": { + "failure": 4, + "success": 23, + "last": { + "date": 1387144346, + "success": true + } + } + } + ] } ], "free": "3.2GB" @@ -203,6 +237,7 @@ Retrieve all files "free": "3.2GB" } + :param force: If set to ``true``, forces a refresh, overriding the cache. :param recursive: If set to ``true``, return all files and folders recursively. Otherwise only return items on same level. :statuscode 200: No error @@ -214,13 +249,18 @@ Retrieve files from specific location .. http:get:: /api/files/(string:location) Retrieve information regarding the files currently available on the selected `location` and -- if targeting - the ``local`` location -- regarding the disk space still available locally in the system. + the ``local`` location -- regarding the disk space still available locally in the system. The results are cached for performance reasons. If you + want to override the cache, supply the query parameter ``force`` and set it to ``true``. + Note that while printing a refresh/override of the cache for files stored on the printer's SD card + is disabled due to bandwidth restrictions on the serial interface. By default only returns the files and folders in the root directory. If the query parameter ``recursive`` is provided and set to ``true``, returns all files and folders. Returns a :ref:`Retrieve response `. + Requires the ``FILES_LIST`` permission. + **Example**: .. sourcecode:: http @@ -272,6 +312,7 @@ Retrieve files from specific location :param location: The origin location from which to retrieve the files. Currently only ``local`` and ``sdcard`` are supported, with ``local`` referring to files stored in OctoPrint's ``uploads`` folder and ``sdcard`` referring to files stored on the printer's SD card (if available). + :param force: If set to ``true``, forces a refresh, overriding the cache. :param recursive: If set to ``true``, return all files and folders recursively. Otherwise only return items on same level. :statuscode 200: No error :statuscode 404: If `location` is neither ``local`` nor ``sdcard`` @@ -286,7 +327,8 @@ Upload file or create folder Upload a file to the selected ``location`` or create a new empty folder on it. Other than most of the other requests on OctoPrint's API which are expected as JSON, this request is expected as - ``Content-Type: multipart/form-data`` due to the included file upload. + ``Content-Type: multipart/form-data`` due to the included file upload. A ``Content-Length`` header specifying + the full length of the request body is required as well. To upload a file, the request body must at least contain the ``file`` form field with the contents and file name of the file to upload. @@ -298,7 +340,7 @@ Upload file or create folder Returns a :http:statuscode:`201` response with a ``Location`` header set to the management URL of the uploaded file and an :ref:`Upload Response ` as the body upon successful completion. - Requires user rights. + Requires the ``FILES_UPLOAD`` permission. **Example for uploading a file** @@ -308,6 +350,7 @@ Upload file or create folder Host: example.com X-Api-Key: abcdef... Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDeC2E3iWbTv1PwMC + Content-Length: 430 ------WebKitFormBoundaryDeC2E3iWbTv1PwMC Content-Disposition: form-data; name="file"; filename="whistle_v2.gcode" @@ -317,7 +360,7 @@ Upload file or create folder T0 G21 G90 - ... + ------WebKitFormBoundaryDeC2E3iWbTv1PwMC Content-Disposition: form-data; name="select" @@ -367,6 +410,7 @@ Upload file or create folder Host: example.com X-Api-Key: abcdef... Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDeC2E3iWbTv1PwMC + Content-Length: 263 ------WebKitFormBoundaryDeC2E3iWbTv1PwMC Content-Disposition: form-data; name="file"; filename*=utf-8''20mm-%C3%BCml%C3%A4ut-b%C3%B6x.gcode @@ -376,7 +420,7 @@ Upload file or create folder T0 G21 G90 - ... + ------WebKitFormBoundaryDeC2E3iWbTv1PwMC-- .. sourcecode:: http @@ -407,6 +451,7 @@ Upload file or create folder Host: example.com X-Api-Key: abcdef... Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDeC2E3iWbTv1PwMD + Content-Length: 246 ------WebKitFormBoundaryDeC2E3iWbTv1PwMD Content-Disposition: form-data; name="foldername" @@ -443,7 +488,7 @@ Upload file or create folder :form select: Whether to select the file directly after upload (``true``) or not (``false``). Optional, defaults to ``false``. Ignored when creating a folder. :form print: Whether to start printing the file directly after upload (``true``) or not (``false``). If set, ``select`` - is implicitely ``true`` as well. Optional, defaults to ``false``. Ignored when creating a folder. + is implicitly ``true`` as well. Optional, defaults to ``false``. Ignored when creating a folder. :form userdata: [Optional] An optional string that if specified will be interpreted as JSON and then saved along with the file as metadata (metadata key ``userdata``). Ignored when creating a folder. :form foldername: The name of the folder to create. Ignored when uploading a file. @@ -475,6 +520,8 @@ Retrieve a specific file's or folder's information On success, a :http:statuscode:`200` is returned, with a :ref:`file information item ` as the response body. + Requires the ``FILES_LIST`` permission. + **Example** .. sourcecode:: http @@ -537,7 +584,19 @@ Issue a file command is not operational when this parameter is present and set to ``true``, the request will fail with a response of ``409 Conflict``. - Upon success, a status code of :http:statuscode:`204` and an empty body is returned. + Upon success, a status code of :http:statuscode:`204` and an empty body is returned. If there already is an + active print job, a :http:statuscode:`409` is returned. + + Requires the ``FILES_SELECT`` permission. + + unselect + Unselects the currently selected file for printing. + + Upon success, a status code of :http:statuscode:`204` and an empty body is returned. If no file is selected + or there already is an active print job, a :http:statuscode:`409` is returned. If path isn't ``current``` + or the filename of the current selection, a :http:statuscode:`400` is returned + + Requires the ``FILES_SELECT`` permission. slice Slices an STL file into GCODE. Note that this is an asynchronous operation that will take place in the background @@ -569,6 +628,8 @@ Issue a file command Upon success, a status code of :http:statuscode:`202` and a :ref:`sec-api-datamodel-files-fileabridged` in the response body will be returned. + Requires the ``SLICE`` permission. + copy Copies the file or folder to a new ``destination`` on the same ``location``. Additional parameters are: @@ -580,6 +641,8 @@ Issue a file command Upon success, a status code of :http:statuscode:`201` and a :ref:`sec-api-datamodel-files-fileabridged` in the response body will be returned. + Requires the ``FILES_UPLOAD`` permission. + move Moves the file or folder to a new ``destination`` on the same ``location``. Additional parameters are: @@ -593,7 +656,7 @@ Issue a file command Upon success, a status code of :http:statuscode:`201` and a :ref:`sec-api-datamodel-files-fileabridged` in the response body will be returned. - Requires user rights. + Requires the ``FILES_UPLOAD`` permission. **Example Select Request** @@ -727,7 +790,8 @@ Issue a file command :statuscode 400: If the ``command`` is unknown or the request is otherwise invalid :statuscode 415: If a ``slice`` command was issued against something other than an STL file. :statuscode 404: If ``location`` is neither ``local`` nor ``sdcard`` or the requested file was not found - :statuscode 409: If a selected file is supposed to start printing directly but the printer is not operational or + :statuscode 409: If a selected file is supposed to start printing directly but the printer is not operational + or if a file is to be selected but the printer is already printing or if a file to be sliced is supposed to be selected or start printing directly but the printer is not operational or already printing. @@ -744,7 +808,7 @@ Delete file Returns a :http:statuscode:`204` after successful deletion. - Requires user rights. + Requires the ``FILES_DELETE`` permission. **Example Request** diff --git a/docs/api/general.rst b/docs/api/general.rst index 91e6735714..8eaede945a 100644 --- a/docs/api/general.rst +++ b/docs/api/general.rst @@ -11,11 +11,14 @@ General information Authorization ============= -OctoPrint's API expects an API key to be supplied with each request. This API key can be either the globally -configured one, a user specific one if "Access Control" is enabled or an ref:`App Session Key `. -Users are able to generate and revoke their custom API key via the "Change password" dialog. +If :ref:`Access Control ` is enabled OctoPrint's API expects an API key to be supplied with each request. This API +key can be either the globally configured one, a user specific one or an app and user specific one as generated by the +authorization workflow implemented by the bundled :ref:`Application Keys Plugin ` (since 1.3.10). -The API key must be supplied in the custom HTTP header ``X-Api-Key``, e.g. +Clients are advised to implement the :ref:`Application Keys Plugin workflow ` first and +fallback on directing the user to manually supply the the user specific API key. The global key should rarely be used. + +The API key must either be supplied in the custom HTTP header ``X-Api-Key``, e.g. .. sourcecode:: http @@ -23,7 +26,13 @@ The API key must be supplied in the custom HTTP header ``X-Api-Key``, e.g. Host: example.com X-Api-Key: abcdef... -If it is missing or included but invalid, OctoPrint will directly return a response with status :http:statuscode:`401`. +or as a ``Bearer`` token in the ``Authorization`` header, e.g. + +.. sourcecode:: http + + GET /api/files HTTP/1.1 + Host: example.com + Authorization: Bearer abcdef... For testing purposes it is also possible to supply the API key via a query parameter ``apikey``, e.g. @@ -34,6 +43,23 @@ For testing purposes it is also possible to supply the API key via a query param Please be advised that clients should use the header field variant if at all possible. +If the key is missing or invalid, OctoPrint will treat the request as it would any unauthenticated anonymous request to the endpoint. +That means that any requests without or with an invalid API key targeting other API endpoints than :ref:`Login ` +will be denied with a :http:statuscode:`403`. + +.. warning:: + + If :ref:`Access Control ` is disabled, OctoPrint will treat any unauthenticated anonymous requests and thus also requests + with an invalid or outright missing API key as requests with full admin rights! + +.. note:: + + The API key requirements changed in 1.3.11. Up to that version, even if Access Control was disabled, all requests needed to + be supplied with an API Key. To make the webinterface work under these circumstances, an unauthenticated anonymous API key was injected into the + HTML page and also available on the :ref:`Push API `. The presence and ready availability of this unauthenticated + anonymous "UI API key" caused confusion and false alarm among users and didn't contribute to the security of the platform in a + meaningful way, so it was finally abandoned in 1.3.11. + .. _fig-api-general-globalapikey: .. figure:: ../images/settings-global-api-key.png :align: center @@ -55,17 +81,12 @@ Please be advised that clients should use the header field variant if at all pos The API key options in the "Change password" dialog. Users can generate and revoke their custom API key here. -.. note:: - OctoPrint's web interface uses a custom API key that is freshly generated on every server start. This key is not - intended to be used by any other client and would not be very useful in any case, since it basically represents - a completely anonymous client. - .. _sec-api-general-contenttype: Content Type ============ -If not otherwise stated OctoPrint's API expects request bodies and issues response bodies as ``Content-Type: application/json``. +If not otherwise stated, OctoPrint's API expects request bodies and issues response bodies as ``Content-Type: application/json``. .. _sec-api-general-encoding: @@ -112,7 +133,8 @@ check the corresponding checkbox in the API settings dialog. Support for CORS can be enabled in the "API" settings -.. note:: +.. warning:: + This means any browser page can send requests to the OctoPrint API. Authorization is still required however. If CORS is not enabled you will get errors like the following:: @@ -120,3 +142,132 @@ If CORS is not enabled you will get errors like the following:: XMLHttpRequest cannot load http://localhost:8081/api/files. No 'Access-Control-Allow-Origin' header is present on the requested resource. + +.. note:: + + For security reasons, OctoPrint will not set the ``Access-Control-Allow-Credentials`` + header, even if CORS support is enabled. That means that cookies will not be sent by + the browser to OctoPrint, effectively making it impossible to authenticate through + the login mechanism (or reusing an existing login session). When accessing OctoPrint + via CORS, you'll therefore always need to use an API key. + +.. _sec-api-general-login: + +Login +===== + +.. http:post:: /api/login + + Creates a login session or retrieves information about the currently existing session ("passive login"). + + Can be used in one of two ways: to login a user via username and password and create a persistent session (usually + from a UI in the browser), or to retrieve information about the active user (from an existing session or an API key) + via the ``passive`` flag. + + Will return a :http:statuscode:`200` with a :ref:`login response ` on successful + login, whether active or passive. The active (username/password) login may also return a :http:statuscode:`403` in + case of a username/password mismatch, unknown user or a deactivated account. + + .. warning:: + + Previous versions of this API endpoint did return a :http:statuscode:`401` in case of a username/password + mismatch or an unknown user. That was incompatible with basic authentication since it was a wrong use of + the :http:statuscode:`401` code and got therefore changed as part of a bug fix. + + .. note:: + + You cannot use this endpoint to login from a third party page via CORS, see above. You can however use it + to retrieve user information via passive login with an API key (e.g. if you need the ``session`` to authenticate + on the web socket. + + :json passive: If present, performs a passive login only, returning information about the current user that's + active either through an existing session or the used API key + :json user: (active login only) Username + :json pass: (active login only) Password + :json remember: (active login only) Whether to set a "remember me" cookie on the session + :status 200: Successful login + :status 403: Username/password mismatch, unknown user or deactivated account + +.. _sec-api-general-logout: + +Logout +====== + +.. http:post:: /api/logout + + Ends the current login session of the current user. + + Only makes sense in the context of browser based workflows. + + Will return a :http:statuscode:`204`. + + :status 204: No error + +.. _sec-api-general-currentuser: + +Current User +============ + +.. http:get:: /api/currentuser + + Retrieves information about the current user. + + Will return a :http:statuscode:`200` with a :ref:`current user object ` + as body. + + :status 200: No error + +.. _sec-api-general-datamodel: + +Data model +========== + +.. _sec-api-general-datamodel-login: + +Login response +-------------- + +The Login response is a :ref:`user record ` extended by the following fields: + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``session`` + - 1 + - string + - The session key, can be used to authenticate with the ``auth`` message on the :ref:`push API `. + * - ``_is_external_client`` + - 1 + - boolean + - Whether the client that made the request got detected as external from the local network or not. + +.. _sec-api-general-datamodel-currentuser: + +Current user +------------ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``name`` + - 1 + - string + - The id of the current user. Unset if guest. + * - ``permissions`` + - 0..n + - List of :ref:`permission records ` + - The effective list of permissions assigned to the user + * - ``groups`` + - 0..n + - List of :ref:`permission records ` + - The list of groups assigned to the user diff --git a/docs/api/index.rst b/docs/api/index.rst index 6eb090eb15..f6e953b387 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -9,7 +9,7 @@ REST API general.rst version.rst - apps.rst + server.rst connection.rst files.rst job.rst @@ -21,7 +21,7 @@ REST API slicing.rst system.rst timelapse.rst - users.rst + access.rst util.rst wizard.rst push.rst diff --git a/docs/api/job.rst b/docs/api/job.rst index 338a10ea7b..e957294592 100644 --- a/docs/api/job.rst +++ b/docs/api/job.rst @@ -57,7 +57,7 @@ Issue a job command Upon success, a status code of :http:statuscode:`204` and an empty body is returned. - Requires user rights. + Requires the ``PRINT`` permission. **Example Start Request** @@ -180,6 +180,8 @@ Retrieve information about the current job Returns a :http:statuscode:`200` with a :ref:`sec-api-job-datamodel-response` in the body. + Requires the ``STATUS`` permission. + **Example** .. sourcecode:: http @@ -203,8 +205,10 @@ Retrieve information about the current job }, "estimatedPrintTime": 8811, "filament": { - "length": 810, - "volume": 5.36 + "tool0": { + "length": 810, + "volume": 5.36 + } } }, "progress": { @@ -212,7 +216,8 @@ Retrieve information about the current job "filepos": 337942, "printTime": 276, "printTimeLeft": 912 - } + }, + "state": "Printing" } :statuscode 200: No error @@ -243,4 +248,13 @@ Job information response - 1 - :ref:`sec-api-datamodel-jobs-progress` - Information regarding the progress of the current print job - + * - ``state`` + - 1 + - String + - A textual representation of the current state of the job or connection, e.g. "Operational", "Printing", "Pausing", "Paused", + "Cancelling", "Error", "Offline", "Offline after error", "Opening serial connection", ... -- please note + that this list is not exhaustive! + * - ``error`` + - 0..1 + - String + - Any error message for the job or connection, only set if there has been an error. diff --git a/docs/api/languages.rst b/docs/api/languages.rst index ecb83102a2..f9a580500c 100644 --- a/docs/api/languages.rst +++ b/docs/api/languages.rst @@ -6,7 +6,7 @@ Languages .. note:: - All language pack management operations require admin rights. + All language pack management operations require the ``SETTINGS`` permission. .. contents:: diff --git a/docs/api/logs.rst b/docs/api/logs.rst index ee1c19561c..f357494df9 100644 --- a/docs/api/logs.rst +++ b/docs/api/logs.rst @@ -4,172 +4,9 @@ Log file management ******************* -.. note:: +Log file management (and logging configuration) was moved into a bundled plugin in OctoPrint 1.3.7. Refer to +:ref:`the Logging's plugins API ` for the API documentation. - All log file management operations require admin rights. - -.. contents:: - -.. _sec-api-logs-list: - -Retrieve a list of available log files -====================================== - -.. http:get:: /api/logs - - Retrieve information regarding all log files currently available and regarding the disk space still available - in the system on the location the log files are being stored. - - Returns a :ref:`Logfile Retrieve response `. - - **Example** - - .. sourcecode:: http - - GET /api/logs HTTP/1.1 - Host: example.com - X-Api-Key: abcdef... - - .. sourcecode:: http - - HTTP/1.1 200 OK - Content-Type: application/json - - { - "files" : [ - { - "date" : 1393158814, - "name" : "octoprint.log", - "size" : 43712, - "refs": { - "resource": "http://example.com/api/logs/octoprint.log", - "download": "http://example.com/downloads/logs/octoprint.log" - } - }, - { - "date" : 1392628936, - "name" : "octoprint.log.2014-02-17", - "size" : 13205, - "refs": { - "resource": "http://example.com/api/logs/octoprint.log.2014-02-17", - "download": "http://example.com/downloads/logs/octoprint.log.2014-02-17" - } - }, - { - "date" : 1393158814, - "name" : "serial.log", - "size" : 1798419, - "refs": { - "resource": "http://example.com/api/logs/serial.log", - "download": "http://example.com/downloads/logs/serial.log" - } - } - ], - "free": 12237201408 - } - - :statuscode 200: No error - :statuscode 403: If the given API token did not have admin rights associated with it - -.. _sec-api-logs-delete: - -Delete a specific logfile -========================= - -.. http:delete:: /api/logs/(path:filename) - - Delete the selected log file with name `filename`. - - Returns a :http:statuscode:`204` after successful deletion. - - **Example Request** - - .. sourcecode:: http - - DELETE /api/logs/octoprint.log.2014-02-17 HTTP/1.1 - Host: example.com - X-Api-Key: abcdef... - - :param filename: The filename of the log file to delete - :statuscode 204: No error - :statuscode 403: If the given API token did not have admin rights associated with it - :statuscode 404: If the file was not found - -.. _sec-api-logs-datamodel: - -Data model -========== - -.. _sec-api-logs-datamodel-retrieveresponse: - -Logfile Retrieve Response -------------------------- - -.. list-table:: - :widths: 15 5 10 30 - :header-rows: 1 - - * - Name - - Multiplicity - - Type - - Description - * - ``files`` - - 0..* - - Array of :ref:`File information items ` - - The list of requested files. Might be an empty list if no files are available - * - ``free`` - - 1 - - String - - The amount of disk space in bytes available in the local disk space (refers to OctoPrint's ``logs`` folder). - -.. _sec-api-logs-datamodel-fileinfo: - -File information ----------------- - -.. list-table:: - :widths: 15 5 10 30 - :header-rows: 1 - - * - Name - - Multiplicity - - Type - - Description - * - ``name`` - - 1 - - String - - The name of the file - * - ``size`` - - 1 - - Number - - The size of the file in bytes. - * - ``date`` - - 1 - - Unix timestamp - - The timestamp when this file was last modified. - * - ``refs`` - - 1 - - :ref:`sec-api-logs-datamodel-ref` - - References relevant to this file - -.. _sec-api-logs-datamodel-ref: - -References ----------- - -.. list-table:: - :widths: 15 5 10 30 - :header-rows: 1 - - * - Name - - Multiplicity - - Type - - Description - * - ``resource`` - - 1 - - URL - - The resource that represents the file (e.g. for deleting) - * - ``download`` - - 1 - - URL - - The download URL for the file +The former endpoints ``/api/logs`` and ``api/logs/`` are marked as deprecated but still work for now. New +client implementations should directly use the new endpoints provided by the bundled plugin. Existing implementations +should adapt their endpoints as soon as possible. diff --git a/docs/api/printer.rst b/docs/api/printer.rst index 200f421082..70237c5ebb 100644 --- a/docs/api/printer.rst +++ b/docs/api/printer.rst @@ -22,6 +22,11 @@ Bed corresponding resource returns temperature information including an optional history. Note that Bed commands are only available if the currently selected printer profile has a heated bed. See :ref:`sec-api-printer-bedcommand`. +Chamber + Chamber commands allow setting the temperature and temperature offset for the printer's heated chamber. Querying + the corresponding resource returns temperature information including an option history. Note that Chamber commands + are only available if the currently selected printer profile has a heated chamber. + See :ref:`sec-api-printer-chambercommand`. SD card SD commands allow initialization, refresh and release of the printer's SD card (if available). Querying the corresponding resource returns the current SD card state. @@ -37,7 +42,7 @@ Besides that, OctoPrint also provides a :ref:`full state report of the printer < OctoPrint's internal webserver is single threaded and can only handle one request at a time. This is not a problem generally since asynchronous programming allows to just have one request which is waiting for data from a long running backend operation to sleep while handling other requests. The internal framework - used for providing the REST API though, Flask, is based on WSGI, which is synchrounous in nature. This means + used for providing the REST API though, Flask, is based on WSGI, which is synchronous in nature. This means that it is impossible to wait in a non blocking wait while handling a request on the REST API. So in order to return the response of a command sent to the printer, the single thread of the webserver would have to be blocked by the API while the response wasn't available yet. Which in turn would mean that the whole web server would @@ -75,6 +80,8 @@ Retrieve the current printer state Returns a :http:statuscode:`200` with a :ref:`Full State Response ` in the body upon success. + Requires the ``STATUS`` permission. + **Example 1** Include temperature history data, but limit it to two entries. @@ -149,6 +156,8 @@ Retrieve the current printer state "operational": true, "paused": false, "printing": false, + "cancelling": false, + "pausing": false, "sdReady": true, "error": false, "ready": true, @@ -179,6 +188,8 @@ Retrieve the current printer state "operational": true, "paused": false, "printing": false, + "cancelling": false, + "pausing": false, "sdReady": true, "error": false, "ready": true, @@ -213,9 +224,9 @@ Issue a print head command * ``z``: Optional. Amount/coordinate to jog print head on z axis, must be a valid number corresponding to the distance to travel in mm. * ``absolute``: Optional. Boolean value specifying whether to move relative to current position (provided axes values are relative amounts) or to absolute position (provided axes values are coordinates) - * ``speed``: Optiona. Speed at which to move. If not provided, minimum speed for all selected axes from printer + * ``speed``: Optional. Speed at which to move. If not provided, minimum speed for all selected axes from printer profile will be used. If provided but ``false``, no speed parameter will be appended to the command. Otherwise - interpreted as an integer signifying the speed in mm/s, to append to the command. + interpreted as an integer signifying the speed in mm/min, to append to the command. home Homes the print head in all of the given axes. Additional parameters are: @@ -223,16 +234,16 @@ Issue a print head command * ``axes``: A list of axes which to home, valid values are one or more of ``x``, ``y``, ``z``. feedrate - Changes the feedrate factor to apply to the movement's of the axes. + Changes the feedrate factor to apply to the movements of the axes. - * ``factor``: The new factor, percentage as integer or float (percentage divided by 100) between 50 and 200%. + * ``factor``: The new factor, percentage between 50 and 200% as integer (``50`` to ``200``) or float (``0.5`` to ``2.0``). All of these commands except ``feedrate`` may only be sent if the printer is currently operational and not printing. Otherwise a :http:statuscode:`409` is returned. Upon success, a status code of :http:statuscode:`204` and an empty body is returned. - Requires user rights. + Requires the ``CONTROL`` permission. **Example Jog Request** @@ -276,9 +287,9 @@ Issue a print head command HTTP/1.1 204 No Content - **Example feed rate request** + **Example feed rate request (1/2)** - Set the feed rate factor to 105%. + Set the feed rate factor to 105% using an integer argument. .. sourcecode:: http @@ -296,6 +307,26 @@ Issue a print head command HTTP/1.1 204 No Content + **Example feed rate request (2/2)** + + Set the feed rate factor to 105% using a float argument. + + .. sourcecode:: http + + POST /api/printer/printhead HTTP/1.1 + Host: example.com + Content-Type: application/json + X-Api-Key: abcdef... + + { + "command": "feedrate", + "factor": 1.05 + } + + .. sourcecode:: http + + HTTP/1.1 204 No Content + :json string command: The command to issue, either ``jog`` or ``home``. :json number x: ``jog`` command: The amount to travel on the X axis in mm. :json number y: ``jog`` command: The amount to travel on the Y axis in mm. @@ -321,7 +352,7 @@ Issue a tool command Sets the given target temperature on the printer's tools. Additional parameters: * ``targets``: Target temperature(s) to set, properties must match the format ``tool{n}`` with ``n`` being the - tool's index starting with 0. + tool's index starting with 0. A value of `0` will turn the heater off. offset Sets the given temperature offset on the printer's tools. Additional parameters: @@ -338,18 +369,20 @@ Issue a tool command Extrudes the given amount of filament from the currently selected tool. Additional parameters: * ``amount``: The amount of filament to extrude in mm. May be negative to retract. + * ``speed``: Optional. Speed at which to extrude. If not provided, maximum speed for E axis from printer + profile will be used. Otherwise interpreted as an integer signifying the speed in mm/min, to append to the command. flowrate Changes the flow rate factor to apply to extrusion of the tool. - * ``factor``: The new factor, percentage as integer or float (percentage divided by 100) between 75 and 125%. + * ``factor``: The new factor, percentage between 75 and 125% as integer (``75`` to ``125``) or float (``0.75`` to ``1.25``). All of these commands may only be sent if the printer is currently operational and -- in case of ``select`` and ``extrude`` -- not printing. Otherwise a :http:statuscode:`409` is returned. Upon success, a status code of :http:statuscode:`204` and an empty body is returned. - Requires user rights. + Requires the ``CONTROL`` permission. **Example Target Temperature Request** @@ -457,9 +490,9 @@ Issue a tool command HTTP/1.1 204 No Content - **Example flow rate request** + **Example flow rate request (1/2)** - Set the flow rate factor to 95%. + Set the flow rate factor to 95% using an integer attribute. .. sourcecode:: http @@ -477,6 +510,26 @@ Issue a tool command HTTP/1.1 204 No Content + **Example flow rate request (2/2)** + + Set the flow rate factor to 95% using a float attribute. + + .. sourcecode:: http + + POST /api/printer/tool HTTP/1.1 + Host: example.com + Content-Type: application/json + X-Api-Key: abcdef... + + { + "command": "flowrate", + "factor": 0.95 + } + + .. sourcecode:: http + + HTTP/1.1 204 No Content + :json string command: The command to issue, either ``target``, ``offset``, ``select`` or ``extrude``. :json object targets: ``target`` command: The target temperatures to set. Valid properties have to match the format ``tool{n}``. :json object offsets: ``offset`` command: The offset temperature to set. Valid properties have to match the format ``tool{n}``. @@ -504,6 +557,8 @@ Retrieve the current tool state Returns a :http:statuscode:`200` with a Temperature Response in the body upon success. + Requires the ``STATUS`` permission. + .. note:: If you want both tool and bed temperature information at the same time, take a look at :ref:`Retrieve the current printer state `. @@ -577,12 +632,12 @@ Issue a bed command are: target - Sets the given target temperature on the printer's tools. Additional parameters: + Sets the given target temperature on the printer's bed. Additional parameters: - * ``target``: Target temperature to set. + * ``target``: Target temperature to set. A value of `0` will turn the heater off. offset - Sets the given temperature offset on the printer's tools. Additional parameters: + Sets the given temperature offset on the printer's bed. Additional parameters: * ``offset``: Offset to set. @@ -594,7 +649,7 @@ Issue a bed command If no heated bed is configured for the currently selected printer profile, the resource will return an :http:statuscode:`409`. - Requires user rights. + Requires the ``CONTROL`` permission. **Example Target Temperature Request** @@ -663,8 +718,10 @@ Retrieve the current bed state If no heated bed is configured for the currently selected printer profile, the resource will return an :http:statuscode:`409`. + Requires the ``STATUS`` permission. + .. note:: - If you want both tool and bed temperature information at the same time, take a look at + If you want tool, bed and chamber temperature information at the same time, take a look at :ref:`Retrieve the current printer state `. **Example** @@ -714,6 +771,156 @@ Retrieve the current bed state :statuscode 409: If the printer is not operational or the selected printer profile does not have a heated bed. +.. _sec-api-printer-chambercommand: + +Issue a chamber command +======================= + +.. http:post:: /api/printer/chamber + + Chamber commands allow setting the temperature and temperature offsets for the printer's heated chamber. Available commands + are: + + target + Sets the given target temperature on the printer's chamber. Additional parameters: + + * ``target``: Target temperature to set. A value of `0` will turn the heater off. + + offset + Sets the given temperature offset on the printer's chamber. Additional parameters: + + * ``offset``: Offset to set. + + All of these commands may only be sent if the printer is currently operational. Otherwise a :http:statuscode:`409` + is returned. + + Upon success, a status code of :http:statuscode:`204` and an empty body is returned. + + If no heated chamber is configured for the currently selected printer profile, the resource will return + an :http:statuscode:`409`. + + Requires the ``CONTROL`` permission. + + **Example Target Temperature Request** + + Set the target temperature for the printer's heated chamber to 50°C. + + .. sourcecode:: http + + POST /api/printer/chamber HTTP/1.1 + Host: example.com + Content-Type: application/json + X-Api-Key: abcdef... + + { + "command": "target", + "target": 50 + } + + .. sourcecode:: http + + HTTP/1.1 204 No Content + + **Example Offset Temperature Request** + + Set the temperature offset for the heated chamber to -5°C. + + .. sourcecode:: http + + POST /api/printer/chamber HTTP/1.1 + Host: example.com + Content-Type: application/json + X-Api-Key: abcdef... + + { + "command": "offset", + "offset": -5 + } + + .. sourcecode:: http + + HTTP/1.1 204 No Content + + :json string command: The command to issue, either ``target`` or ``offset``. + :json object target: ``target`` command: The target temperature to set. + :json object offset: ``offset`` command: The offset temperature to set. + :statuscode 204: No error + :statuscode 400: If ``target`` or ``offset`` is not a valid number or outside of the supported range, or if the + request is otherwise invalid. + :statuscode 409: If the printer is not operational or the selected printer profile + does not have a heated chamber. + +.. _sec-api-printer-chamberstate: + +Retrieve the current chamber state +================================== + +.. http:get:: /api/printer/chamber + + Retrieves the current temperature data (actual, target and offset) plus optionally a (limited) history (actual, target, + timestamp) for the printer's heated chamber. + + It's also possible to retrieve the temperature history by supplying the ``history`` query parameter set to ``true``. The + amount of returned history data points can be limited using the ``limit`` query parameter. + + Returns a :http:statuscode:`200` with a Temperature Response in the body upon success. + + If no heated chamber is configured for the currently selected printer profile, the resource will return + an :http:statuscode:`409`. + + Requires the ``STATUS`` permission. + + .. note:: + If you want tool, bed and chamber temperature information at the same time, take a look at + :ref:`Retrieve the current printer state `. + + **Example** + + Query the chamber temperature data and also include the temperature history but limit it to two entries. + + .. sourcecode:: http + + GET /api/printer/chamber?history=true&limit=2 HTTP/1.1 + Host: example.com + X-Api-Key: abcdef... + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "chamber": { + "actual": 50.221, + "target": 70.0, + "offset": 5 + }, + "history": [ + { + "time": 1395651928, + "chamber": { + "actual": 50.221, + "target": 70.0 + } + }, + { + "time": 1395651926, + "chamber": { + "actual": 49.1123, + "target": 70.0 + } + } + ] + } + + :query history: If set to ``true`` (or: ``yes``, ``y``, ``1``), history information will be included in the response + too. If no ``limit`` parameter is given, all available temperature history data will be returned. + :query limit: If set to an integer (``n``), only the last ``n`` data points from the printer's temperature history + will be returned. Will be ignored if ``history`` is not enabled. + :statuscode 200: No error + :statuscode 409: If the printer is not operational or the selected printer profile + does not have a heated chamber. + .. _sec-api-printer-sdcommand: Issue an SD command @@ -745,7 +952,7 @@ Issue an SD command Upon success, a status code of :http:statuscode:`204` and an empty body is returned. - Requires user rights. + Requires the ``CONTROL`` permission. **Example Init Request** @@ -823,6 +1030,8 @@ Retrieve the current SD state Returns a :http:statuscode:`200` with an :ref:`SD State Response ` in the body upon success. + Requires the ``STATUS`` permission. + **Example** Read the current state of the SD card. @@ -859,7 +1068,7 @@ Send an arbitrary command to the printer If successful returns a :http:statuscode:`204` and an empty body. - Requires user rights. + Requires the ``CONTROL`` permission. **Example for sending a single command** @@ -902,6 +1111,26 @@ Send an arbitrary command to the printer :json string commands: List of commands to send to the printer, mutually exclusive with ``command``. :statuscode 204: No error +.. _sec-api-printer-customcontrols: + +Retrieve custom controls +======================== + +.. http:get:: /api/printer/command/custom + + Retrieves the :ref:`custom controls ` as configured in + :ref:`config.yaml `. + + Please refer to the documentation of :ref:`custom controls ` on what + data structure to expect here. + + Returns a :http:statuscode:`200` with an :ref:`Custom Controls Response ` + in the body upon success. + + Requires the ``CONTROL`` permission. + + :statuscode 200: No error + .. _sec-api-printer-datamodel: Data model @@ -1012,3 +1241,21 @@ Arbitrary Command Request - 0..1 - Map of key value pairs - (only if ``script`` is set) additional template variables to provide to the script renderer + +.. _sec-api-printer-datamodel-customcontrols: + +Custom Controls Response +------------------------ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``controls`` + - 0..n + - List of :ref:`custom controls ` + - A list of custom control definitions as defined in ``config.yaml``. diff --git a/docs/api/printerprofiles.rst b/docs/api/printerprofiles.rst index 835399d5c6..c0b2792e2a 100644 --- a/docs/api/printerprofiles.rst +++ b/docs/api/printerprofiles.rst @@ -17,9 +17,11 @@ Retrieve all printer profiles .. http:get:: /api/printerprofiles - Retrieves a list of all configured printer profiles. + Retrieves an object representing all configured printer profiles. - Returns a :http:statuscode:`200` with a list of :ref:`profiles `. + Returns a :http:statuscode:`200` with a :ref:`profile list `. + + Requires the ``CONNECTION`` permission. **Example** @@ -35,8 +37,8 @@ Retrieve all printer profiles Content-Type: application/json { - "profiles": [ - { + "profiles": { + "_default": { "id": "_default", "name": "Default", "color": "default", @@ -52,6 +54,7 @@ Retrieve all printer profiles "height": 200 }, "heatedBed": true, + "heatedChamber": false, "axes": { "x": { "speed": 6000, @@ -77,7 +80,7 @@ Retrieve all printer profiles ] } }, - { + "my_profile": { "id": "my_profile", "name": "My Profile", "color": "default", @@ -93,6 +96,7 @@ Retrieve all printer profiles "height": 200 }, "heatedBed": true, + "heatedChamber": true, "axes": { "x": { "speed": 6000, @@ -118,10 +122,26 @@ Retrieve all printer profiles ] } }, - ] + } } +.. _sec-api-printerprofiles-get: + +Retrieve a single printer profile +================================= + +.. http:get:: /api/printerprofiles/(string:identifier) + + Retrieves an existing single printer profile. + + Returns a :http:statuscode:`200` with a :ref:`profile `. + + Requires the ``CONNECTION`` permission. + + :statuscode 200: No error + :statuscode 404: The profile does not exist + .. _sec-api-printerprofiles-add: Add a new printer profile @@ -141,7 +161,7 @@ Add a new printer profile Returns a :http:statuscode:`200` with the saved profile as property ``profile`` in the JSON body upon success. - Requires admin rights. + Requires the ``SETTINGS`` permission. **Example 1** @@ -184,6 +204,7 @@ Add a new printer profile "height": 200 }, "heatedBed": true, + "heatedChamber": false, "axes": { "x": { "speed": 6000, @@ -267,6 +288,7 @@ Add a new printer profile "height": 300 }, "heatedBed": false, + "heatedChamber": false, "axes": { "x": { "speed": 6000, @@ -310,7 +332,7 @@ Update an existing printer profile Returns a :http:statuscode:`200` with the saved profile as property ``profile`` in the JSON body upon success. - Requires admin rights. + Requires the ``SETTINGS`` permission. **Example** @@ -352,6 +374,7 @@ Update an existing printer profile "height": 200 }, "heatedBed": true, + "heatedChamber": false, "axes": { "x": { "speed": 6000, @@ -395,7 +418,7 @@ Remove an existing printer profile Returns a :http:statuscode:`204` an empty body upon success. - Requires admin rights. + Requires the ``SETTINGS`` permission. **Example** @@ -434,7 +457,7 @@ Profile list - Collection of all printer profiles available in the system * - ``profiles.`` - 0..1 - - :ref:`Profile ` + - :ref:`Profile ` - Information about a profile stored in the system. .. _sec-api-printerprofiles-datamodel-update: @@ -452,7 +475,7 @@ Add or update request - Description * - ``profiles`` - 1 - - :ref:`Profile ` + - :ref:`Profile ` - Information about the profile being added/updated. Only the values to be overwritten need to be supplied. Unset fields will be taken from the base profile, which for add requests will be the current default profile unless a different base is defined in the ``basedOn`` property @@ -578,10 +601,14 @@ Profile - 0..1 - ``boolean`` - Whether the printer has a heated bed (``true``) or not (``false``) + * - ``heatedChamber`` + - 0..1 + - ``boolean`` + - Whether the printer has a heated chamber (``true``) or not (``false``) * - ``axes`` - 0..1 - Object - - Description of the printer's axes properties, one entry each for ``x``, ``y``, ``z`` and ``e`` holding maxium speed + - Description of the printer's axes properties, one entry each for ``x``, ``y``, ``z`` and ``e`` holding maximum speed and whether this axis is inverted or not. * - ``axes.{axis}.speed`` - 0..1 diff --git a/docs/api/push.rst b/docs/api/push.rst index 7d88138670..5210ad5993 100644 --- a/docs/api/push.rst +++ b/docs/api/push.rst @@ -29,6 +29,9 @@ following message types are currently available for usage by 3rd party clients: * ``connected``: Initial connection information, sent only right after establishing the socket connection. See :ref:`the payload data model `. + * ``reauthRequired``: A reauthentication of the current login session is required. The ``reason`` parameter in the + payload defines whether a full active login is necessary (values ``logout`` and ``removed``) or a simple passive + login will suffice (all other values). * ``current``: Rate limited general state update, payload contains information about the printer's state, job progress, accumulated temperature points and log lines since last update. OctoPrint will send these updates when new information is available, but not more often than twice per second in order to not flood the client with messages (e.g. @@ -47,23 +50,57 @@ Clients must ignore any unknown messages. The data model of the attached payloads is described further below. -OctoPrint's SockJS socket also accepts one command from the client to the server, -the ``throttle`` command. Usually, OctoPrint will push the general state update -in the ``current`` message twice per second. For some clients that might still -be too fast, so they can signal a different factor to OctoPrint utilizing the -``throttle`` message. OctoPrint expects a single integer here which represents -the multiplier for the base rate limit of one message every 500ms. A value of -1 hence will produce the default behaviour of getting every update. A value of -2 will set the rate limit to maximally one message every 1s, 3 to maximally one -message every 1.5s and so on. +OctoPrint's SockJS socket also accepts two commands from the client to the server. -Example for a ``throttle`` client-server-message: + * ``auth`` (since 1.3.10): With the ``auth`` message, clients may associate an + existing user session with the socket. That is of special importance to receive + any kind of messages, since the permission system will prevent any kind of status messages to be sent to connected + clients lacking the ``STATUS`` permission. -.. sourcecode:: javascript + The ``auth`` message expects the user id of the user to authenticate followed by ``:`` and a session key to be + obtained from the successful payload of a :ref:`(passive or active) login via the API `. - { - "throttle": 2 - } + Example for a ``auth`` client-server-message: + + .. sourcecode:: javascript + + { + "auth": "someuser:LGZ0trf8By" + } + + An example for an auth roundtrip with only an API key using the :ref:`JS Client Library ` + can be found :ref:`here `. + + .. mermaid:: + + sequenceDiagram + participant Client + participant API + participant Websocket + + Client->>API: GET /api/login?passive=true&apikey=... + API->>Client: { name: ..., session: ..., ... } + + note over Client: auth = name ":" session + + Client->>Websocket: { "auth": auth } + + * ``throttle``: Usually, OctoPrint will push the general state update + in the ``current`` message twice per second. For some clients that might still + be too fast, so they can signal a different factor to OctoPrint utilizing the + ``throttle`` message. OctoPrint expects a single integer here which represents + the multiplier for the base rate limit of one message every 500ms. A value of + 1 hence will produce the default behaviour of getting every update. A value of + 2 will set the rate limit to maximally one message every 1s, 3 to maximally one + message every 1.5s and so on. + + Example for a ``throttle`` client-server-message: + + .. sourcecode:: javascript + + { + "throttle": 2 + } .. _sec-api-push-datamodel: @@ -157,6 +194,15 @@ Data model - 0..* - List of String - Lines for the serial communication log (special messages) + * - ``resends`` + - 1 + - :ref:`Resend stats ` + - Current resend statistics for the connection + * - ``plugins`` + - 0..1 + - Map of plugin identifiers to additional data + - Additional data injected by plugins via the :ref:`octoprint.printer.additional_state_data hook `, + indexed by plugin identifier. Structure of additional data is determined by the plugin. .. _sec-api-push-datamodel-event: diff --git a/docs/api/server.rst b/docs/api/server.rst new file mode 100644 index 0000000000..50849e3010 --- /dev/null +++ b/docs/api/server.rst @@ -0,0 +1,36 @@ +.. _sec-api-server: + +****************** +Server information +****************** + +.. http:get:: /api/server + + .. versionadded:: 1.5.0 + + Retrieve information regarding server status. Returns a JSON object with two keys, ``version`` containing + the server version and ``safemode`` containing one of ``settings``, ``incomplete_startup`` or ``flag`` + to indicate the reason the server is running in safe mode, or the boolean value of ``false`` if it's not + running in safe mode. + + **Example Request** + + .. sourcecode:: http + + GET /api/server HTTP/1.1 + Host: example.com + X-Api-Key: abcdef... + + **Example Response** + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "server": "1.5.0", + "safemode": "incomplete_startup" + } + + :statuscode 200: No error diff --git a/docs/api/settings.rst b/docs/api/settings.rst index b26b2d25d7..68bfa5dab6 100644 --- a/docs/api/settings.rst +++ b/docs/api/settings.rst @@ -21,6 +21,8 @@ Retrieve current settings The :ref:`data model ` is similar to what can be found in :ref:`config.yaml `, see below for details. + Requires the ``SETTINGS_READ`` permission. + .. _sec-api-settings-save: Save settings @@ -36,7 +38,7 @@ Save settings Returns the currently active settings on success, as part of a :http:statuscode:`200` response. - Requires admin rights. + Requires the ``SETTINGS`` permission. **Example** @@ -86,90 +88,288 @@ Regenerate the system wide API key :status 200: No error :status 403: No admin rights +.. _sec-api-settings-fetchtemplaatedata: + +Fetch template data +=================== + +.. http:get:: /api/settings/templates + + Fetch data (currently only the sorting order) of all registered template components in the system. + + Use this to get a full list of the identifiers of all UI components provided either by core OctoPrint or any + currently active plugins. + + Example: + + .. sourcecode:: http + + GET /api/settings/templates HTTP/1.1 + Host: example.com + X-Api-Key: abcdef... + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "order": { + "about": [ + { + "id": "about", + "name": "About OctoPrint" + }, + { + "id": "supporters", + "name": "Supporters" + }, + { + "id": "authors", + "name": "Authors" + }, + { + "id": "changelog", + "name": "Changelog" + }, + { + "id": "license", + "name": "OctoPrint License" + }, + { + "id": "thirdparty", + "name": "Third Party Licenses" + }, + { + "id": "plugin_pluginmanager", + "name": "Plugin Licenses", + "plugin_id": "pluginmanager", + "plugin_name": "Plugin Manager" + } + ], + "generic": [ + { + "id": "plugin_announcements", + "name": "plugin_announcements", + "plugin_id": "announcements", + "plugin_name": "Announcement Plugin" + } + ], + "navbar": [ + { + "id": "settings", + "name": "settings" + }, + { + "id": "systemmenu", + "name": "systemmenu" + }, + { + "id": "plugin_announcements", + "name": "plugin_announcements", + "plugin_id": "announcements", + "plugin_name": "Announcement Plugin" + }, + { + "id": "login", + "name": "login" + } + ], + "plugin_pluginmanager_about_thirdparty": [], + "settings": [ + { + "id": "section_printer", + "name": "Printer" + }, + { + "id": "serial", + "name": "Serial Connection" + }, + { + "id": "printerprofiles", + "name": "Printer Profiles" + }, + { + "id": "temperatures", + "name": "Temperatures" + }, + { + "id": "terminalfilters", + "name": "Terminal Filters" + }, + { + "id": "gcodescripts", + "name": "GCODE Scripts" + }, + { + "id": "section_features", + "name": "Features" + }, + { + "id": "features", + "name": "Features" + }, + { + "id": "webcam", + "name": "Webcam & Timelapse" + }, + { + "id": "accesscontrol", + "name": "Access Control" + }, + { + "id": "gcodevisualizer", + "name": "GCODE Visualizer" + }, + { + "id": "api", + "name": "API" + }, + { + "id": "section_octoprint", + "name": "OctoPrint" + }, + { + "id": "server", + "name": "Server" + }, + { + "id": "folders", + "name": "Folders" + }, + { + "id": "appearance", + "name": "Appearance" + }, + { + "id": "plugin_logging", + "name": "Logging", + "plugin_id": "logging", + "plugin_name": "Logging" + }, + { + "id": "plugin_pluginmanager", + "name": "Plugin Manager", + "plugin_id": "pluginmanager", + "plugin_name": "Plugin Manager" + }, + { + "id": "plugin_softwareupdate", + "name": "Software Update", + "plugin_id": "softwareupdate", + "plugin_name": "Software Update" + }, + { + "id": "plugin_announcements", + "name": "Announcements", + "plugin_id": "announcements", + "plugin_name": "Announcement Plugin" + }, + { + "id": "section_plugins", + "name": "Plugins" + }, + { + "id": "plugin_action_command_prompt", + "name": "Action Command Prompt", + "plugin_id": "action_command_prompt", + "plugin_name": "Action Command Prompt Support" + }, + { + "id": "plugin_curalegacy", + "name": "Cura Legacy", + "plugin_id": "curalegacy", + "plugin_name": "Cura Legacy" + } + ], + "sidebar": [ + { + "id": "plugin_printer_safety_check", + "name": "Printer Safety Warning", + "plugin_id": "printer_safety_check", + "plugin_name": "Printer Safety Check" + }, + { + "id": "connection", + "name": "Connection" + }, + { + "id": "state", + "name": "State" + }, + { + "id": "files", + "name": "Files" + } + ], + "tab": [ + { + "id": "temperature", + "name": "Temperature" + }, + { + "id": "control", + "name": "Control" + }, + { + "id": "gcodeviewer", + "name": "GCode Viewer" + }, + { + "id": "terminal", + "name": "Terminal" + }, + { + "id": "timelapse", + "name": "Timelapse" + } + ], + "usersettings": [ + { + "id": "access", + "name": "Access" + }, + { + "id": "interface", + "name": "Interface" + } + ], + "wizard": [] + } + } + + Requires admin rights. + + .. warning:: + + This API endpoint is in beta. Things might change. If you happen to want to develop against it, you should drop + me an email to make sure I can give you a heads-up when something changes in an backwards incompatible way. + + :status 200: No error + :status 403: No admin rights + .. _sec-api-settings-datamodel: Data model ========== The data model on the settings API mostly reflects the contents of -:ref:`config.yaml `. The settings tree -returned by the API contains the following fields, which are directly -mapped from the same fields in ``config.yaml`` unless otherwise noted: +:ref:`config.yaml `, which are directly +mapped, with the following exceptions: .. list-table:: :header-rows: 1 * - Field - Notes - * - ``api.enabled`` - - - * - ``api.key`` - - Only maps to ``api.key`` in ``config.yaml`` if request is sent with admin rights, set to ``n/a`` otherwise. - Starting with OctoPrint 1.3.3 setting this field via :ref:`the API ` is not possible, - only :ref:`regenerting it ` is supported. Setting a custom value is only - possible through `config.yaml`. - * - ``api.allowCrossOrigin`` - - - * - ``appearance.name`` - - - * - ``appearance.color`` - - - * - ``appearance.colorTransparent`` - - - * - ``appearance.defaultLanguage`` - - - * - ``appearance.showFahrenheitAlso`` - - * - ``feature.gcodeViewer`` - Maps to ``gcodeViewer.enabled`` in ``config.yaml`` * - ``feature.sizeThreshold`` - Maps to ``gcodeViewer.sizeThreshold`` in ``config.yaml`` * - ``feature.mobileSizeThreshold`` - Maps to ``gcodeViewer.mobileSizeThreshold`` in ``config.yaml`` - * - ``feature.temperatureGraph`` - - - * - ``feature.waitForStart`` - - - * - ``feature.alwaysSendChecksum`` - - - * - ``feature.neverSendChecksum`` - - - * - ``feature.sdSupport`` - - - * - ``feature.sdReleativePath`` - - - * - ``feature.sdAlwaysAvailable`` - - - * - ``feature.swallowOkAfterResend`` - - - * - ``feature.repetierTargetTemp`` - - - * - ``feature.externalHeatupDetection`` - - - * - ``feature.keyboardControl`` - - - * - ``feature.pollWatched`` - - - * - ``feature.ignoreIdenticalResends`` - - - * - ``feature.modelSizeDetection`` - - - * - ``feature.firmwareDetection`` - - - * - ``feature.printCancelConfirmation`` - - - * - ``feature.blockWhileDwelling`` - - - * - ``folder.uploads`` - - - * - ``folder.timelapse`` - - * - ``folder.timelapseTmp`` - Maps to ``folder.timelapse_tmp`` in ``config.yaml`` - * - ``folder.logs`` - - - * - ``folder.watched`` - - * - ``plugins`` - Plugin settings as available from ``config.yaml`` and :class:`~octoprint.plugin.SettingsPlugin` implementations * - ``printer.defaultExtrusionLength`` @@ -184,8 +384,6 @@ mapped from the same fields in ``config.yaml`` unless otherwise noted: - Available serial ports * - ``serial.baudrateOptions`` - Available serial baudrates - * - ``serial.autoconnect`` - - * - ``serial.timeoutConnection`` - Maps to ``serial.timeout.connection`` in ``config.yaml`` * - ``serial.timeoutDetection`` @@ -198,50 +396,18 @@ mapped from the same fields in ``config.yaml`` unless otherwise noted: - Maps to ``serial.timeout.temperatureTargetSet`` in ``config.yaml`` * - ``serial.timeoutSdStatus`` - Maps to ``serial.timeout.sdStatus`` in ``config.yaml`` - * - ``serial.log`` - - - * - ``serial.additionalPorts`` - - - * - ``serial.additionalBaudrates`` - - - * - ``serial.longRunningCommands`` - - - * - ``serial.checksumRequiringCommands`` - - - * - ``serial.helloCommand`` - - - * - ``serial.ignoreErrorsFromFirmware`` - - - * - ``serial.disconnectOnErrors`` - - - * - ``serial.triggerOkForM29`` - - - * - ``serial.supportResendsWIthoutOk`` - - * - ``serial.maxTimeoutsIdle`` - Maps to ``serial.maxCommunicationTimeouts.idle`` in ``config.yaml`` * - ``serial.maxTimeoutsPrinting`` - Maps to ``serial.maxCommunicationTimeouts.printing`` in ``config.yaml`` * - ``serial.maxTimeoutsLong`` - Maps to ``serial.maxCommunicationTimeouts.long`` in ``config.yaml`` - * - ``server.commands.systemShutdownCommand`` - - - * - ``server.commands.systemRestartCommand`` - - - * - ``server.commands.serverRestartCommand`` - - - * - ``server.diskspace.warning`` - - - * - ``server.diskspace.critical`` - - * - ``system.actions`` - Whole subtree taken from ``config.yaml`` * - ``system.events`` - Whole subtree taken from ``config.yaml`` * - ``temperature.profiles`` - Whole subtree taken from ``config.yaml`` - * - ``temperature.cutoff`` - - * - ``terminalFilters`` - Whole subtree taken from ``config.yaml`` * - ``webcam.streamUrl`` @@ -250,15 +416,3 @@ mapped from the same fields in ``config.yaml`` unless otherwise noted: - Maps to ``webcam.snapshot`` in ``config.yaml`` * - ``webcam.ffmpegPath`` - Maps to ``webcam.ffmpeg`` in ``config.yaml`` - * - ``webcam.bitrate`` - - - * - ``webcam.ffmpegThreads`` - - - * - ``webcam.watermark`` - - - * - ``webcam.flipH`` - - - * - ``webcam.flipV`` - - - * - ``webcam.rotate90`` - - diff --git a/docs/api/slicing.rst b/docs/api/slicing.rst index 1296531712..7c1ea53f03 100644 --- a/docs/api/slicing.rst +++ b/docs/api/slicing.rst @@ -28,6 +28,8 @@ List All Slicers and Slicing Profiles Returns a :http:statuscode:`200` response with a :ref:`Slicer list ` as the body upon successful completion. + Requires the ``SLICE`` permission. + **Example** .. sourcecode:: http @@ -42,22 +44,22 @@ List All Slicers and Slicing Profiles Content-Type: application/json { - "cura": { - "key": "cura", - "displayName": "CuraEngine", + "curalegacy": { + "key": "curalegacy", + "displayName": "Cura Legacy", "default": true, "profiles": { "high_quality": { "key": "high_quality", "displayName": "High Quality", "default": false, - "resource": "http://example.com/api/slicing/cura/profiles/high_quality" + "resource": "http://example.com/api/slicing/curalegacy/profiles/high_quality" }, "medium_quality": { "key": "medium_quality", "displayName": "Medium Quality", "default": true, - "resource": "http://example.com/api/slicing/cura/profiles/medium_quality" + "resource": "http://example.com/api/slicing/curalegacy/profiles/medium_quality" } } } @@ -77,11 +79,13 @@ List Slicing Profiles of a Specific Slicer Returns a :http:statuscode:`200` response with a :ref:`Profile list ` as the body upon successful completion. + Requires the ``SLICE`` permission. + **Example** .. sourcecode:: http - GET /api/slicing/cura/profiles HTTP/1.1 + GET /api/slicing/curalegacy/profiles HTTP/1.1 Host: example.com X-Api-Key: abcdef... @@ -95,13 +99,13 @@ List Slicing Profiles of a Specific Slicer "key": "high_quality", "displayName": "High Quality", "default": false, - "resource": "http://example.com/api/slicing/cura/profiles/high_quality" + "resource": "http://example.com/api/slicing/curalegacy/profiles/high_quality" }, "medium_quality": { "key": "medium_quality", "displayName": "Medium Quality", "default": true, - "resource": "http://example.com/api/slicing/cura/profiles/medium_quality" + "resource": "http://example.com/api/slicing/curalegacy/profiles/medium_quality" } } @@ -121,11 +125,13 @@ Retrieve Specific Profile Returns a :http:statuscode:`200` response with a :ref:`full Profile ` as the body upon successful completion. + Requires the ``SLICE`` permission. + **Example** .. sourcecode:: http - GET /api/slicing/cura/profiles/quick_test HTTP/1.1 + GET /api/slicing/curalegacy/profiles/quick_test HTTP/1.1 Host: example.com X-Api-Key: abcdef... @@ -137,7 +143,7 @@ Retrieve Specific Profile { "displayName": "Just a test", "description": "This is just a test", - "resource": "http://example.com/api/slicing/cura/profiles/quick_test", + "resource": "http://example.com/api/slicing/curalegacy/profiles/quick_test", "data": { "bottom_layer_speed": 20.0, "bottom_thickness": 0.3, @@ -168,20 +174,20 @@ Add Slicing Profile Returns a :http:statuscode:`201` and an :ref:`abridged Profile ` in the body upon successful completion. - Requires admin rights. + Requires the ``SETTINGS`` permission. **Example** .. sourcecode:: http - PUT /api/slicing/cura/profiles/quick_test HTTP/1.1 + PUT /api/slicing/curalegacy/profiles/quick_test HTTP/1.1 Host: example.com X-Api-Key: abcdef... Content-Type: application/json { "displayName": "Just a test", - "description": "This is just a test to show how to create a cura profile with a different layer height and skirt count", + "description": "This is just a test to show how to create a curalegacy profile with a different layer height and skirt count", "data": { "layer_height": 0.2, "skirt_line_count": 3 @@ -197,8 +203,8 @@ Add Slicing Profile { "displayName": "Just a test", - "description": "This is just a test to show how to create a cura profile with a different layer height and skirt count", - "resource": "http://example.com/api/slicing/cura/profiles/quick_test" + "description": "This is just a test to show how to create a curalegacy profile with a different layer height and skirt count", + "resource": "http://example.com/api/slicing/curalegacy/profiles/quick_test" } :param slicer: The identifying key of the slicer for which to add the profile @@ -206,6 +212,31 @@ Add Slicing Profile :statuscode 201: No error :statuscode 404: If the ``slicer`` was unknown to the system. +.. _sec-api-slicing-patch: + +Update Slicing Profile +====================== + +.. http:patch:: /api/slicing/(string:slicer)/profiles/(string:key) + + Updates the slicing profile identified by ``key`` for the slicer ``slicer``. + + Expects a :ref:`profile update request ` as body. + + Returns a :http:statuscode:`201` and an :ref:`abridged Profile ` in the body + upon successful completion. + + Requires the ``SETTINGS`` permission. + + :param slicer: The identifying key of the slicer for which to update the profile + :param key: The identifying key of the profile to update + :json data: New profile overrides to apply + :json displayName: New display name + :json description: New description + :json default: Whether to make the profile default (true) or not (false) for the slicer + :statuscode 200: No error + :statuscode 404: Slicer or profile do not exist + .. _sec-api-slicing-delete: Delete Slicing Profile @@ -216,7 +247,7 @@ Delete Slicing Profile Delete the slicing profile identified by ``key`` for the slicer ``slicer``. If the profile doesn't exist, the request will succeed anyway. - Requires admin rights. + Requires the ``SETTINGS`` permission. :param slicer: The identifying key of the slicer for which to delete the profile :param key: The identifying key of the profile to delete @@ -340,3 +371,32 @@ Profile only the keys differing from the defaults when saving/updating a profile. The keys to be found in here a slicer specific. Will be left out for list responses. +.. _sec-api-slicing-datamodel-profileupdate: + +Profile Update Request +---------------------- + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``data`` + - 0..1 + - Object + - The profile data + * - ``displayName`` + - 0..1 + - ``string`` + - The new display name for the profile + * - ``description`` + - 0..1 + - ``string`` + - The new description for the profile + * - ``default`` + - 0..1 + - ``boolean`` + - Whether to mark the profile as default for the slicer (true) or not (false) diff --git a/docs/api/system.rst b/docs/api/system.rst index 9bf0907ff3..4dce5dd582 100644 --- a/docs/api/system.rst +++ b/docs/api/system.rst @@ -6,7 +6,7 @@ System .. note:: - All system operations require admin rights. + All system operations require the ``SYSTEM`` permission. .. _sec-api-system-command-list: @@ -20,6 +20,8 @@ List all registered system commands A :http:statuscode:`200` with a :ref:`List all response ` will be returned. + Requires the ``SYSTEM`` permission. + **Example** .. sourcecode:: http @@ -38,30 +40,21 @@ List all registered system commands { "action": "shutdown", "name": "Shutdown", - "command": "sudo shutdown -h now", "confirm": "You are about to shutdown the system.

This action may disrupt any ongoing print jobs (depending on your printer's controller and general setup that might also apply to prints run directly from your printer's internal storage).", - "async": true, - "ignore": true, "source": "core", "resource": "http://example.com/api/system/commands/core/shutdown" }, { "action": "reboot", "name": "Reboot", - "command": "sudo reboot", "confirm": "You are about to reboot the system.

This action may disrupt any ongoing print jobs (depending on your printer's controller and general setup that might also apply to prints run directly from your printer's internal storage).", - "async": true, - "ignore": true, "source": "core", "resource": "http://example.com/api/system/commands/core/reboot" }, { "action": "restart", "name": "Restart OctoPrint", - "command": "sudo service octoprint restart", "confirm": "You are about to restart the OctoPrint server.

This action may disrupt any ongoing print jobs (depending on your printer's controller and general setup that might also apply to prints run directly from your printer's internal storage).", - "async": true, - "ignore": true, "source": "core", "resource": "http://example.com/api/system/commands/core/restart" } @@ -99,30 +92,21 @@ List all registered system commands for a source { "action": "shutdown", "name": "Shutdown", - "command": "sudo shutdown -h now", - "confirm": "You are about to shutdown the system.

This action may disrupt any ongoing print jobs (depending on your printer's controller and general setup that might also apply to prints run directly from your printer's internal storage).", - "async": true, - "ignore": true, + "confirm": "You are about to shutdown the system.

This action may disrupt any ongoing print jobs (depending on your printer's controller and general setup that might also apply to prints run directly from your printer's internal storage).", "source": "core", "resource": "http://example.com/api/system/commands/core/shutdown" }, { "action": "reboot", "name": "Reboot", - "command": "sudo reboot", - "confirm": "You are about to reboot the system.

This action may disrupt any ongoing print jobs (depending on your printer's controller and general setup that might also apply to prints run directly from your printer's internal storage).", - "async": true, - "ignore": true, + "confirm": "You are about to reboot the system.

This action may disrupt any ongoing print jobs (depending on your printer's controller and general setup that might also apply to prints run directly from your printer's internal storage).", "source": "core", "resource": "http://example.com/api/system/commands/core/reboot" }, { "action": "restart", "name": "Restart OctoPrint", - "command": "sudo service octoprint restart", - "confirm": "You are about to restart the OctoPrint server.

This action may disrupt any ongoing print jobs (depending on your printer's controller and general setup that might also apply to prints run directly from your printer's internal storage).", - "async": true, - "ignore": true, + "confirm": "You are about to restart the OctoPrint server.

This action may disrupt any ongoing print jobs (depending on your printer's controller and general setup that might also apply to prints run directly from your printer's internal storage).", "source": "core", "resource": "http://example.com/api/system/commands/core/restart" } @@ -139,7 +123,7 @@ Execute a registered system command .. http:post:: /api/system/commands/(string:source)/(string:action) - Execute the system command ``action`` on defined in ``source``. + Execute the system command ``action`` defined in ``source``. **Example** @@ -184,18 +168,60 @@ List all response - Description * - ``core`` - 0..n - - List of :ref:`command definitions ` + - List of :ref:`client command definitions ` - List of all core commands defined. * - ``custom`` - 0..n - - List of :ref:`command definitions ` + - List of :ref:`client command definitions ` - List of all custom commands defined in ``config.yaml``. +.. _sec-api-client-system-commands-definiton: + +Client command definitions +-------------------------- + +A restricted form of the full :ref:`command definition `. +For exposing via the API. + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``name`` + - 1 + - string + - The name of the command to display in the System menu. + * - ``action`` + - 1 + - string + - An identifier to refer to the command programmatically. The special ``action`` string + ``divider`` signifies a divider in the menu. + * - ``confirm`` + - 0..1 + - string + - If present and set, this text will be displayed to the user in a confirmation dialog + they have to acknowledge in order to really execute the command. + * - ``source`` + - 1 + - string + - Source of the command definition, currently either ``core`` (for system actions defined by + OctoPrint itself) or ``custom`` (for custom system commands defined by the user through ``config.yaml``). + * - ``resource`` + - 1 + - string + - The URL of the command to use for executing it. + .. _sec-api-system-commands-definiton: Command definition ------------------ +The full command definition is not available via the API. + .. list-table:: :widths: 15 5 10 30 :header-rows: 1 diff --git a/docs/api/timelapse.rst b/docs/api/timelapse.rst index 914e43490b..66e0bb72e2 100644 --- a/docs/api/timelapse.rst +++ b/docs/api/timelapse.rst @@ -18,6 +18,8 @@ Retrieve a list of timelapses and the current config Returns a :ref:`timelapse list ` in the response body. + Requires the ``TIMELAPSE_LIST`` permission. + :param unrendered: If provided and true, also include unrendered timelapses .. _sec-api-timelapse-delete: @@ -29,7 +31,7 @@ Delete a timelapse Delete the timelapse ``filename``. - Requires user rights. + Requires the ``TIMELAPSE_DELETE`` permission. .. _sec-api-timelapse-render: @@ -41,7 +43,7 @@ Issue a command for an unrendered timelapse Current only supports to render the unrendered timelapse ``name`` via the ``render`` command. - Requires user rights. + Requires the ``TIMELAPSE_ADMIN`` permission. :json command: The command to issue, currently only ``render`` is supported @@ -54,7 +56,7 @@ Delete an unrendered timelapse Delete the unrendered timelapse ``name``. - Requires user rights. + Requires the ``TIMELAPSE_DELETE`` permission. .. _sec-api-timelapse-saveconfig: @@ -67,7 +69,7 @@ Change current timelapse config The configuration is expected as the request body. - Requires user rights. + Requires the ``TIMELAPSE_ADMIN`` permission. .. _sec-api-timelapse-datamodel: @@ -128,7 +130,7 @@ Rendered timelapse * - ``date`` - 1 - string - - Formatted timestamp of the the timelapse creation date + - Formatted timestamp of the timelapse creation date * - ``url`` - 1 - string @@ -162,7 +164,7 @@ Unrendered timelapse * - ``date`` - 1 - string - - Formatted timestamp of the the timelapse job creation date + - Formatted timestamp of the timelapse job creation date * - ``recording`` - 1 - bool @@ -224,6 +226,10 @@ For timelapse type ``zchange``. - 1 - float - Size of retraction Z hop to detect and ignore for z-based snapshots + * - ``minDelay`` + - 1 + - int + - Snapshots will be rate limited against this interval, to prevent performance issues with vase mode/continuous z prints .. _sec-api-timelapse-datamodel-config-timed: diff --git a/docs/api/users.rst b/docs/api/users.rst index 536c955b00..495829a1fa 100644 --- a/docs/api/users.rst +++ b/docs/api/users.rst @@ -4,311 +4,6 @@ User **** -.. contents:: +.. note:: -.. _sec-api-users-list: - -Retrieve a list of users -======================== - -.. http:get:: /api/users - - Retrieves a list of all registered users in OctoPrint. - - Will return a :http:statuscode:`200` with a :ref:`user list response ` - as body. - - Requires admin rights. - - :status 200: No error - -.. _sec-api-users-retrieve: - -Retrieve a user -=============== - -.. http:get:: /api/users/(string:username) - - Retrieves information about a user. - - Will return a :http:statuscode:`200` with a :ref:`user record ` - as body. - - Requires either admin rights or to be logged in as the user. - - :param username: Name of the user which to retrieve - :status 200: No error - :status 404: Unknown user - -.. _sec-api-users-add: - -Add a user -========== - -.. http:post:: /api/users - - Adds a user to OctoPrint. - - Expects a :ref:`user registration request ` - as request body. - - Returns a list of registered users on success, see :ref:`Retrieve a list of users `. - - Requires admin rights. - - :json name: The user's name - :json password: The user's password - :json active: Whether to activate the account (true) or not (false) - :json admin: Whether to give the account admin rights (true) or not (false) - :status 200: No error - :status 400: If any of the mandatory fields is missing or the request is otherwise - invalid - :status 409: A user with the provided name does already exist - -.. _sec-api-users-update: - -Update a user -============= - -.. http:put:: /api/users/(string:username) - - Updates a user record. - - Expects a :ref:`user update request ` - as request body. - - Returns a list of registered users on success, see :ref:`Retrieve a list of users `. - - Requires admin rights. - - :param username: Name of the user to update - :json admin: Whether to mark the user as admin (true) or not (false), can be left out (no change) - :json active: Whether to mark the account as activated (true) or deactivated (false), can be left out (no change) - :status 200: No error - :status 404: Unknown user - -.. _sec-api-users-delete: - -Delete a user -============= - -.. http:delete:: /api/users/(string:username) - - Delete a user record. - - Returns a list of registered users on success, see :ref:`Retrieve a list of users `. - - Requires admin rights. - - :param username: Name of the user to delete - :status 200: No error - :status 404: Unknown user - -.. _sec-api-users-resetpassword: - -Reset a user's password -======================= - -.. http:put:: /api/users/(string:username)/password - - Changes the password of a user. - - Expects a JSON object with a single property ``password`` as request body. - - Requires admin rights or to be logged in as the user. - - :param username: Name of the user to change the password for - :json password: The new password to set - :status 200: No error - :status 400: If the request doesn't contain a ``password`` property or the request - is otherwise invalid - :status 403: No admin rights and not logged in as the user - :status 404: The user is unknown - -.. _sec-api-users-getsettings: - -Retrieve a user's settings -========================== - -.. http:get:: /api/users/(string:username)/settings - - Retrieves a user's settings. - - Will return a :http:statuscode:`200` with a JSON object representing the user's - personal settings (if any) as body. - - Requires admin rights or to be logged in as the user. - - :param username: Name of the user to retrieve the settings for - :status 200: No error - :status 403: No admin rights and not logged in as the user - :status 404: The user is unknown - -.. _sec-api-users-updatesettings: - -Update a user's settings -======================== - -.. http:patch:: /api/users/(string:username)/settings - - Updates a user's settings. - - Expects a new settings JSON object to merge with the current settings as - request body. - - Requires admin rights or to be logged in as the user. - - :param username: Name of the user to retrieve the settings for - :status 204: No error - :status 403: No admin rights and not logged in as the user - :status 404: The user is unknown - -.. _sec-api-users-generateapikey: - -Regenerate a user's personal API key -==================================== - -.. http:post:: /api/users/(string:username)/apikey - - Generates a new API key for the user. - - Does not expect a body. Will return the generated API key as ``apikey`` - property in the JSON object contained in the response body. - - Requires admin rights or to be logged in as the user. - - :param username: Name of the user to retrieve the settings for - :status 200: No error - :status 403: No admin rights and not logged in as the user - :status 404: The user is unknown - -.. _sec-api-users-deleteapikey: - -Delete a user's personal API key -================================ - -.. http:delete:: /api/users/(string:username)/apikey - - Deletes a user's personal API key. - - Requires admin rights or to be logged in as the user. - - :param username: Name of the user to retrieve the settings for - :status 204: No error - :status 403: No admin rights and not logged in as the user - :status 404: The user is unknown - -.. _sec-api-users-datamodel: - -Data model -========== - -.. _sec-api-users-datamodel-userlistresponse: - -User list response ------------------- - -.. list-table:: - :widths: 15 5 10 30 - :header-rows: 1 - - * - Name - - Multiplicity - - Type - - Description - * - ``users`` - - 0..n - - List of :ref:`user records ` - - The list of registered users - -.. _sec-api-users-datamodel-userrecord: - -User record ------------ - -.. list-table:: - :widths: 15 5 10 30 - :header-rows: 1 - - * - Name - - Multiplicity - - Type - - Description - * - ``name`` - - 1 - - string - - The user's name - * - ``active`` - - 1 - - bool - - Whether the user's account is active (true) or not (false) - * - ``user`` - - 1 - - bool - - Whether the user has user rights. Should always be true. - * - ``admin`` - - 1 - - bool - - Whether the user has admin rights (true) or not (false) - * - ``apikey`` - - 0..1 - - string - - The user's personal API key - * - ``settings`` - - 1 - - object - - The user's personal settings, might be an empty object. - -.. _sec-api-users-datamodel-adduserrequest: - -User registration request -------------------------- - -.. list-table:: - :widths: 15 5 10 30 - :header-rows: 1 - - * - Name - - Multiplicity - - Type - - Description - * - ``name`` - - 1 - - string - - The user's name - * - ``password`` - - 1 - - string - - The user's password - * - ``active`` - - 1 - - bool - - Whether to activate the account (true) or not (false) - * - ``admin`` - - 0..1 - - bool - - Whether to give the user admin rights (true) or not (false or not present) - -.. _sec-api-users-datamodel-updateuserrequest: - -User update request -------------------- - -.. list-table:: - :widths: 15 5 10 30 - :header-rows: 1 - - * - Name - - Multiplicity - - Type - - Description - * - ``active`` - - 0..1 - - bool - - If present will set the user's active flag to the provided value. True for - activating the account, false for deactivating it. - * - ``admin`` - - 0..1 - - bool - - If present will set the user's admin flag to the provided value. True for - admin rights, false for no admin rights. + This API endpoint has been moved and is now part of :ref:`Access Control `. diff --git a/docs/api/util.rst b/docs/api/util.rst index 1ee12ff22c..bb1271240a 100644 --- a/docs/api/util.rst +++ b/docs/api/util.rst @@ -6,8 +6,8 @@ Util .. _sec-api-util-test: -Test paths or URLs -================== +Various tests +============= .. http:post:: /api/util/test @@ -30,6 +30,14 @@ Test paths or URLs existence but also whether it is of the specified type. Optional. * ``check_access``: A list of any of ``r``, ``w`` and ``x``. If present it will also be checked if OctoPrint has read, write, execute permissions on the specified path. + * ``allow_create_dir``: If ``check_type`` is provided and set to ``dir``, this will allow + OctoPrint to create the target ``path`` as a directory if it doesn't yet exist to allow + for further tests. + * ``check_writable_dir``: If ``check_type`` is provided and set to ``dir``, this will + check that the provided ``path`` is a writable directory. OctoPrint not only check if the + permissions on the directory allow for writing but also attempt to write (and delete) a + small test file ``.testballoon.txt`` to the directory to test if writing is actually + possible. The ``path`` command returns a :http:statuscode:`200` with a :ref:`path test result ` when the test could be performed. The status code of the response does NOT reflect the @@ -38,13 +46,15 @@ Test paths or URLs .. _sec-api-util-test-url: url - Tests whether a provided url responds. Request method and expected status codes can + Tests whether a provided URL responds. Request method and expected status codes can optionally be specified as well. Supported parameters are: - * ``url``: The url to test. Mandatory. + * ``url``: The URL to test. Mandatory. * ``method``: The request method to use for the test. Optional, defaults to ``HEAD``. * ``timeout``: A timeout for the request, in seconds. If no reply from the tested URL has been received within this time frame, the check will be considered a failure. Optional, defaults to 3 seconds. + * ``validSsl``: Whether to validate the SSL connection if the ``url`` happens to be an HTTPS URL or not. Optional, + defaults to ``True``. * ``status``: The status code(s) or named status range(s) to test for. Can be either a single value or a list of either HTTP status codes or any of the following named status ranges: @@ -63,6 +73,12 @@ Test paths or URLs from the URL check will be returned as part of the check result as well. ``json`` will attempt to parse the response as json and return the parsed result. ``true`` or ``bytes`` will base64 encode the body and return that. + * ``content_type_whitelist``: Optional array of supported content types. If set and the URL returns a content + type not included in this list, the test will fail. E.g. ``["image/*", "text/plain"]``. + * ``content_type_blacklist``: Optional array of unsupported content types. If set and the URL returns a content + type included in this list, the test wil fail. E.g. ``["video/*"]``. Can be used together with ``content_type_whitelist`` + to further limit broader content type definition, e.g. by putting ``image/*`` into the whitelist, but disallowing + PNG by including ``image/png`` on the blacklist. The ``url`` command returns :http:statuscode:`200` with a :ref:`URL test result ` when the test could be performed. The status code of the response does NOT reflect the @@ -83,7 +99,24 @@ Test paths or URLs The ``server`` command returns :http:statuscode:`200` with a :ref:`Server test result ` when the test could be performed. The status code of the response does NOT reflect the test result! - Requires admin rights. + resolution + Tests whether a provided hostname can be resolved (via DNS lookup). Supported parameters are: + + * ``name``: The host name to test. Mandatory. + + The ``resolution`` command returns :http:statuscode:`200` with a :ref:`Resolution test result ` + when the test could be performed. The status code of the response does NOT reflect the test result! + + address + Tests whether a provided address (or, if none is provided, the client's remote address) is + a LAN address and if so returns the subnet specifier in CIDR format. + + * ``address``: the address to test. If not set, the client's remote address will be used + + The ``address`` command return :http:statuscode:`200` with a :ref:`Address test result ` + when the test could be performed. The status code of the response does NOT reflect the test result! + + Requires the ``ADMIN`` permission. **Example 1** @@ -270,19 +303,103 @@ Test paths or URLs "result": true } - :json command: The command to execute, currently either ``path`` or ``url`` - :json path: ``path`` command only: the path to test - :json check_type: ``path`` command only: the type of path to test for, either ``file`` or ``dir`` - :json check_access: ``path`` command only: a list of access permissions to check for - :json url: ``url`` command only: the URL to test - :json status: ``url`` command only: one or more expected status codes - :json method: ``url`` command only: the HTTP method to use for the check - :json timeout: ``url`` and ``server`` commands only: the timeout for the test request - :json response: ``url`` command only: whether to include response data and if so in what form - :json host: ``server`` command only: the server to test - :json port: ``server`` command only: the port to test - :json protocol: ``server`` command only: the protocol to test - :statuscode 200: No error occurred + **Example 7** + + Test whether a host name can be resolved successfully. + + .. sourcecode:: http + + POST /api/util/test HTTP/1.1 + Host: example.com + X-Api-Key: abcdef... + Content-Type: application/json + + { + "command": "resolution", + "name": "octoprint" + } + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "name": "octoprint.org", + "result": true + } + + **Example 8** + + Test whether the client's address is a LAN address. + + .. sourcecode:: http + + POST /api/util/test HTTP/1.1 + Host: example.com + X-Api-Key: abcdef... + Content-Type: application/json + + { + "command": "address" + } + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "address": "192.168.1.3", + "is_lan_address": true, + "subnet": "192.168.0.0/16" + } + + **Example 9** + + Test whether ``8.8.8.8`` is a LAN address. + + .. sourcecode:: http + + POST /api/util/test HTTP/1.1 + Host: example.com + X-Api-Key: abcdef... + Content-Type: application/json + + { + "command": "address", + "address": "8.8.8.8" + } + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "address": "8.8.8.8", + "is_lan_address": false + } + + :json command: The command to execute, currently either ``path`` or ``url`` + :json path: ``path`` command only: the path to test + :json check_type: ``path`` command only: the type of path to test for, either ``file`` or ``dir`` + :json check_access: ``path`` command only: a list of access permissions to check for + :json allow_create_dir: ``path`` command and ``checktype`` of ``dir`` only: whether to allow creation of the + directory if it doesn't yet exist (``true``) or not (``false``, default) + :json check_writable_dir: ``path`` command and ``checktype`` of ``dir`` only: whether to test if the directory + is writable by also trying to create a test file in it (``true``) or not (``false``, default) + :json url: ``url`` command only: the URL to test + :json status: ``url`` command only: one or more expected status codes + :json method: ``url`` command only: the HTTP method to use for the check + :json timeout: ``url`` and ``server`` commands only: the timeout for the test request + :json response: ``url`` command only: whether to include response data and if so in what form + :json host: ``server`` command only: the server to test + :json port: ``server`` command only: the port to test + :json protocol: ``server`` command only: the protocol to test + :json name: ``resolution`` command only: the host name to test + :json address: ``address`` command only: the address to test + :statuscode 200: No error occurred .. _sec-api-util-datamodel: @@ -389,3 +506,51 @@ Server test result - 1 - bool - ``true`` if the check passed. + +.. _sec-api-util-datamodel-resolutiontestresult: + +Resolution test result +---------------------- + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``name`` + - 1 + - string + - The host name that was tested. + * - ``result`` + - 1 + - bool + - ``true`` if the check passed. + +.. _sec-api-util-datamodel-addresstestresult: + +Address test result +------------------- + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``address`` + - 1 + - string + - The address that was tested. + * - ``is_lan_address`` + - 1 + - bool + - ``true`` if the address is a LAN address, false otherwise. + * - ``subnet`` + - 0..1 + - string + - The detected subnet, if address is a LAN address. diff --git a/docs/api/version.rst b/docs/api/version.rst index 47b0c56e04..3072420ab9 100644 --- a/docs/api/version.rst +++ b/docs/api/version.rst @@ -6,8 +6,9 @@ Version information .. http:get:: /api/version - Retrieve information regarding server and API version. Returns a JSON object with two keys, ``api`` containing - the API version, ``server`` containing the server version. + Retrieve information regarding server and API version. Returns a JSON object with three keys, ``api`` containing + the API version, ``server`` containing the server version, ``text`` containing the server version including + the prefix ``OctoPrint`` (to determine that this is indeed a genuine OctoPrint instance). **Example Request** @@ -26,7 +27,8 @@ Version information { "api": "0.1", - "server": "1.1.0" + "server": "1.3.10", + "text": "OctoPrint 1.3.10" } :statuscode 200: No error diff --git a/docs/api/wizard.rst b/docs/api/wizard.rst index dfbb138017..6a36e3ed5f 100644 --- a/docs/api/wizard.rst +++ b/docs/api/wizard.rst @@ -6,7 +6,7 @@ Wizard .. note:: - All wizard operations require either admin rights or the ``firstRun`` flag to be ``true``. + All wizard operations require either the ``ADMIN`` permission or the ``firstRun`` flag to be ``true``. .. contents:: diff --git a/docs/bundledplugins/action_command_notification.rst b/docs/bundledplugins/action_command_notification.rst new file mode 100644 index 0000000000..549ad23966 --- /dev/null +++ b/docs/bundledplugins/action_command_notification.rst @@ -0,0 +1,61 @@ +.. _sec-bundledplugins-action_command_notification: + +Action Command Notification support +=================================== + +.. versionadded:: 1.4.1 + +The OctoPrint Action Command Notification Support Plugin comes bundled with OctoPrint. + +It allows firmware to trigger notifications users with the ``PLUGIN_ACTION_COMMAND_NOTIFICATION_SHOW`` permission +using :ref:`action commands ` about relevant events. These notifications will be shown +in the sidebar and if configured also as popup messages. The notifications in the sidebar panel can also be cleared +by users with the ``PLUGIN_ACTION_COMMAND_NOTIFICATION_CLEAR`` permission. + +.. _fig-bundledplugins-action_command_notification-example: +.. figure:: ../images/bundledplugins-action_command_notification-example.png + :align: center + :alt: Simple notification example + + A simple notification example triggered by the firmware + +.. _sec-bundledplugins-action_command_notification-configuration: + +Configuring the plugin +---------------------- + +The plugin supports the following configuration keys: + + * ``enable``: Whether to enable the support of notifications (enabled by default). + * ``enable_popup``: Whether to enable the additional display of notifications as popups in the UI (disabled by default). + +You can modify them either through the plugin's configuration dialog under Settings, or by directly editing ``config.yaml``. + +.. _sec-bundledplugins-action_command_notification-action_commands: + +Supported action commands +------------------------- + +notification + Displays the notification ```` + +.. _sec-bundledplugins-action_command_notification-example: + +Example communication with the firmware +--------------------------------------- + +To display the :ref:`above notification ` the firmware sent this action command: + +.. code-block:: none + + //action:notification Hello World! + + +.. _sec-bundledplugins-action_command_notification-sourcecode: + + +Source code +----------- + +The source of the Action Command Prompt Notification plugin is bundled with OctoPrint and can be found in +its source repository under ``src/octoprint/plugins/action_command_notification``. diff --git a/docs/bundledplugins/action_command_prompt.rst b/docs/bundledplugins/action_command_prompt.rst new file mode 100644 index 0000000000..b4e09d623e --- /dev/null +++ b/docs/bundledplugins/action_command_prompt.rst @@ -0,0 +1,106 @@ +.. _sec-bundledplugins-action_command_prompt: + +Action Command Prompt support +============================= + +.. versionadded:: 1.3.9 + +The OctoPrint Action Command Prompt Support Plugin comes bundled with OctoPrint. + +It allows firmware to trigger dialog prompts for logged in users using :ref:`action commands `, e.g. to wait +for user acknowledgement or allow the user to choose between options, and also to close the dialog again +in case its no longer needed, e.g. due to the user proceeding on the printer's own controller. + +The choice of the user can be communicated back to the firmware via a configurable GCODE command with a +placeholder for the selected option's index. + +.. _fig-bundledplugins-action_command_prompt-example: +.. figure:: ../images/bundledplugins-action_command_prompt-example.png + :align: center + :alt: Simple prompt example + + A simple dialog example triggered by the firmware + +.. _sec-bundledplugins-action_command_prompt-configuration: + +Configuring the plugin +---------------------- + +The plugin supports the following configuration keys: + + * ``enable``: Whether to always enable (``always``), disable (``never``) or ``detect`` support. + * ``enable_emergency_sending``: Will make the selection command be sent as an emergency command to jump the internal + send queue and even be sent to the printer if it's not signaling to be able to received. Only done if this is true + and the printer signals support for emergency command processing. Defaults to ``true``. + * ``enable_signaling``: If enabled, will send the configured command with the ``P1`` parameter to the printer to + signal prompt support of the host. Defaults to ``true``. + * ``command``: The command to send to the firmware on choice, defaults to ``M876``. + +You can modify them either through the plugin's configuration dialog under Settings, or by directly editing ``config.yaml``. + +.. _sec-bundledplugins-action_command_prompt-action_commands: + +Supported action commands +------------------------- + +prompt_begin + Starts the definition of a prompt dialog. ```` is the message to display to the user. Will be ignored + if a dialog is already defined. + +prompt_choice + Defines a choice with the associated ````. Will be ignored if no dialog has been defined yet. + +prompt_button + Same as ``prompt_choice``. + +prompt_show + Tells OctoPrint that the dialog is now fully defined and to prompt the user. Will be ignored if no dialog is + defined yet. + +prompt_end + Tells OctoPrint that the dialog should now be closed (e.g. the user made the choice on the printer directly instead + of through OctoPrint). Will be ignored if no dialog is defined yet. + +.. _sec-bundledplugins-action_command_prompt-example: + +Example communication with the firmware +--------------------------------------- + +To display the :ref:`above dialog ` the firmware sent these action commands: + +.. code-block:: none + + //action:prompt_begin Filament unloaded, swap then proceed + //action:prompt_choice Filament swapped + //action:prompt_show + +If the user clicks the button, OctoPrint will send back ``M876 S0`` (0-based index). + +A more complicated example with three options would be the following: + +.. code-block:: none + + //action:prompt_begin Filament runout detected. Please choose how to proceed: + //action:prompt_choice Swap filament + //action:prompt_choice Home X/Y and pause print + //action:prompt_choice Abort print + //action:prompt_show + +This would produce the following output: + +.. _fig-bundledplugins-action_command_prompt-example2: +.. figure:: ../images/bundledplugins-action_command_prompt-example2.png + :align: center + :alt: Another prompt example + + Another dialog example triggered by the firmware + +If the user selects "Abort print", OctoPrint will send ``2`` as selected choice. + +.. _sec-bundledplugins-action_command_prompt-sourcecode: + +Source code +----------- + +The source of the Action Command Prompt Support plugin is bundled with OctoPrint and can be found in +its source repository under ``src/octoprint/plugins/action_command_prompt``. diff --git a/docs/bundledplugins/announcements.rst b/docs/bundledplugins/announcements.rst new file mode 100644 index 0000000000..c8104efaec --- /dev/null +++ b/docs/bundledplugins/announcements.rst @@ -0,0 +1,69 @@ +.. _sec-bundledplugins-announcements: + +Announcement Plugin +=================== + +.. versionadded:: 1.2.11 + +The OctoPrint Announcement Plugin comes bundled with OctoPrint. + +It displays announcements fetched from OctoPrint's websites, such as important updates, release announcements, new +plugins and similar. Through some manual configuration via ``config.yaml`` it also allows adding additional channels +via RSS or Atom feed URLs. + +.. _fig-bundledplugins-announcements-example1: +.. figure:: ../images/bundledplugins-announcements-example1.png + :align: center + :alt: Example notification + + An example notification from the Announcement Plugin. + +.. _fig-bundledplugins-announcements-example2: +.. figure:: ../images/bundledplugins-announcements-example2.png + :align: center + :alt: Announcement reader + + The Announcement Plugin's reader dialog. + +.. _fig-bundledplugins-announcements-settings: +.. figure:: ../images/bundledplugins-announcements-settings.png + :align: center + :alt: Announcement settings + + The Announcement Plugin's settings dialog. + +.. _sec-bundledplugins-announcements-configuration: + +Configuring the plugin +---------------------- + +The plugin allows enabling and disabling the preconfigured announcement channels via its settings dialog. + +On top of that it's possible to add additional announcement channels (as RSS or Atom feed URLs) or change the +configuration of the existing channels through ``config.yaml``. + +The available configuration keys are: + + * ``channels``: Configured announcement channels. A mapping from a unique identifier to a configuration structure + that follows this format: + + * ``name``: Name of the channel, used e.g. in the title of the displayed notifications. + * ``description``: Description of the channel. + * ``priority``: ``1`` for high priority announcements (red notification), ``2`` for regular ones (yellow notification). + * ``type``: Currently unused. + * ``url``: URL for the feed acting as notification source, must be RSS or Atom format. + + * ``enabled_channels``: List of identifiers of enabled channels. + * ``forced_channels``: List of identifiers of channels that cannot be disabled (currently only ``_important`` which is used sparingly). + * ``channel_order``: List of channel identifiers in the order they should appear in the UI. + * ``ttl``: Time to live of the channel cache in minutes (default: 6 hours). + * ``display_limit``: Limit of items to display per channel (default: 3). + * ``summary_limit``: Limit of characters to display from each channel entry (default: 300). + +.. _sec-bundledplugins-announcements-sourcecode: + +Source code +----------- + +The source of the Announcement plugin is bundled with OctoPrint and can be found in +its source repository under ``src/octoprint/plugins/announcements``. diff --git a/docs/bundledplugins/appkeys.rst b/docs/bundledplugins/appkeys.rst new file mode 100644 index 0000000000..89bdceec11 --- /dev/null +++ b/docs/bundledplugins/appkeys.rst @@ -0,0 +1,545 @@ +.. _sec-bundledplugins-appkeys: + +Application Keys Plugin +======================= + +.. versionadded:: 1.3.10 + +The OctoPrint Application Keys Plugin comes bundled with OctoPrint. + +It implements a workflow for third party apps or clients to obtain an application specific API key from OctoPrint to interact with it +on a user's behalf, via confirmation of the user through OctoPrint's web interface. Existing keys can be managed +on a per-user base as well as globally by administrators. + +.. _fig-bundledplugins-appkeys-confirmation: +.. figure:: ../images/bundledplugins-appkeys-confirmation_prompt.png + :align: center + :alt: Confirmation + + The plugin's confirmation generated for a new application key request. + +.. _fig-bundledplugins-appkeys-user_settings: +.. figure:: ../images/bundledplugins-appkeys-user_settings.png + :align: center + :alt: Key management via user settings + + Users can manage the application specific API keys registered to their account via their user settings. + +.. _fig-bundledplugins-appkeys-settings: +.. figure:: ../images/bundledplugins-appkeys-settings.png + :align: center + :alt: Global key management via settings + + Administrators can manage all application specific API keys registered to any user. + +.. _sec-bundledplugins-appkeys-workflow: + +Workflow +-------- + +The workflow to receive an API key for a third party client/an app via the Application Keys Plugin should consist +of the following steps: + + 1. The User opens the App and gets prompted to enter or select an instance URL. Optionally (recommended!) the User also + enters their username which is also their user ID into the App. + 2. The App :ref:`probes for workflow support ` on the Server. If this request + doesn't get an HTTP :http:statuscode:`204` the App needs to direct the user to an alternative manual workflow + (copy-paste API key) and abort this one. Otherwise it proceeds to the next step. + 3. The App sends a :ref:`key request ` to the Server to start the + authorization process. + 4. The Server triggers a confirmation dialog for the User on the Webinterface and returns an endpoint to the + App to poll for a decision in the ``Location`` header of an HTTP :http:statuscode:`201`. + 5. The App uses the obtained request specific endpoint to poll for a decision every second. An HTTP :http:statuscode:`202` + signals that no decision has been made yet. + 6. The User either accepts or denies the access request which makes the Webinterface send a + :ref:`decision request ` to the Server. + 7. If the User accepted the request, the App receives an HTTP :http:statuscode:`200` with an attached + :ref:`API key response `. If they deny it, the App will receive + an HTTP :http:statuscode:`404`. + + +.. mermaid:: + + sequenceDiagram + participant User + participant App + participant Webinterface + participant Server + + note over User, Server: Step 1, 2 & 3 + + User->>App: enters URL of instance to connect to and optional user_id + + App->>Server: GET /plugin/appkeys/probe + + alt Workflow unsupported + + Server->>App: 404 + App->>User: alternative workflow, copy-paste key manually + + else Workflow supported + + App->>Server: POST /plugin/appkeys/request, (app_name, user_id) + + note over User, Server: Step 4 + + Server-->>Webinterface: plugin message for "appkeys" w/ (app_name, user_token, user_id) + Webinterface-->>User: Display confirmation dialog + Server->>App: 201, Location: /plugin/appkeys/request/ + + note over User, Server: Step 5 + + loop Poll for decision + App->>Server: GET /plugin/appkeys/request/ + Server->>App: 202 + end + + note over User, Server: Step 6 & 7 + + alt User accepts + + User-->>Webinterface: Allow access + Webinterface->>Server: POST /plugin/appkeys/decision/, (True) + Server->>Webinterface: 204 + App->>Server: GET /plugin/appkeys/request/ + Server->>App: 200, api_key + + else User denies + + User-->>Webinterface: Deny access + Webinterface->>Server: POST /plugin/appkeys/decision/, (False) + Server->>Webinterface: 204 + App->>Server: GET /plugin/appkeys/request/ + Server->>App: 404 + + end + + end + +.. _sec-bundledplugins-appkeys-api: + +API +--- + +.. _sec-bundledplugins-appkeys-api-probe: + +Probe for workflow support +.......................... + +.. http:get:: /plugin/appkeys/probe + + Probes for support of the workflow. + + Normally returns an HTTP :http:statuscode:`204`, indicating workflow availability. If a different status code is returned + (usually an HTTP :http:statuscode:`404`), the plugin is disabled or not installed. Fall back to manual api key exchange. + + :status 204: the workflow is supported + +.. _sec-bundledplugins-appkeys-api-startauthprocess: + +Start authorization process +........................... + +.. http:post:: /plugin/appkeys/request + + Starts the authorization process. + + Expects a :ref:`Key request ` as request body. + + The ``app`` parameter should be a human readable identifier to use + for the application requesting access. It will be displayed to the user. Internally it will be used case insensitively, + so ``My App`` and ``my APP`` are considered the same application identifiers. + + The optional ``user`` parameter should be used to limit the authorization process to a specified user. If the parameter + is left unset, any user will be able to complete the authorization process and grant access to the app with their + account. E.g. if a user ``me`` starts the process in an app, the app should request that name from the user and use + it in the ``user`` parameter. OctoPrint will then only display the authorization request on browsers the user ``me`` + is logged in on. + + :json app: application identifier to use for the request, case insensitive + :json user: optional user id to restrict the decision to the specified user + :status 201: authorization process started, polling URL to query can be found in ``Location`` header + +.. _sec-bundledplugins-appkeys-api-polldecision: + +Poll for decision on existing request +..................................... + +.. http:get:: /plugin/appkeys/request/ + + Endpoint generated per authorization request to poll for the result. + + Returns an HTTP :http:statuscode:`202` while no decision has been made yet, an HTTP :http:statuscode:`200` and + a :ref:`Key response ` if access has been granted and an + HTTP :http:statuscode:`404` if the request has been denied or timed out. + + .. note:: + + The request will be considered stale and deleted internally if the polling endpoint for it isn't called + for more than 5s. + + :status 200: access granted, API key in response body + :status 202: no decision has been made yet, continue polling + :status 404: access denied or request timed out + +.. _sec-bundledplugins-appkeys-api-decide: + +Decide on existing request +.......................... + +.. http:post:: /plugin/appkeys/decision/ + + Endpoint to decide on the authorization request. + + Expects a :ref:`Decision request ` as request body. + + Returns an HTTP :http:statuscode:`204` on success. + + :json decision: boolean value to indicate whether to confirm (``True``) or deny (``False``) access + :status 204: success + +.. _sec-bundledplugins-appkeys-api-fetchlist: + +Fetch list of existing application keys +....................................... + +.. http:get:: /api/plugin/appkeys + + Fetches a list of existing application keys and pending requests registered in the system for the current user. + + If the additional optional parameter ``all`` is provided and the user has administrator rights, fetches a list + of *all** application keys and pending requests registered in the system for any user. + + Returns a :http:statuscode:`200` with a :ref:`List response ` in the + body upon success. + + :query all: Fetch all application keys and pending requests from all users. Requires administrator rights. + +.. _sec-bundledplugins-appkeys-api-issuecommand: + +Issue an application key command +................................ + +.. http:post:: /api/plugin/appkeys + + Application key commands allow revoking existing application keys and manually generating new ones. The available + commands are: + + revoke + Revokes an existing application key. Must belong to the user issuing the command, unless the user has admin rights + in which case they make revoke any application key in the system. Expects the key in question as parameter ``key``. + + generate + Generates a new application key for the user, using the application identifier provided as parameter ``app``. + + Upon success, a status code of :http:statuscode:`204` and an empty body is returned. + + Requires user rights. + + **Example revoke request** + + Revokes the (fictional) key ``aabbccddeeff112233445566``. + + .. sourcecode:: http + + POST /api/plugin/appkeys HTTP/1.1 + Host: example.com + Content-Type: application/json + X-Api-Key: abcdef... + + { + "command": "revoke", + "key": "aabbccddeeff112233445566" + } + + .. sourcecode:: http + + HTTP/1.1 204 No Content + + **Example generate request** + + Generates a new key for application identifier "My awesome application 1.0". + + .. sourcecode:: http + + POST /api/plugin/appkeys HTTP/1.1 + Host: example.com + Content-Type: application/json + X-Api-Key: abcdef... + + { + "command": "generate", + "key": "My awesome application 1.0" + } + + .. sourcecode:: http + + HTTP/1.1 204 No Content + + :json string command: The command to issue, either ``revoke`` or ``generate`` + :json string key: ``revoke`` command: The key to revoke + :json string app: ``generate`` command: Application identifier for which to generate a key + :statuscode 204: No error + :statuscode 400: Invalid or missing parameter + +.. _sec-bundledplugins-appkey-datamodel: + +Data model +---------- + +.. _sec-bundledplugins-appkey-datamodel-keyrequest: + +Key request +........... + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``app`` + - 1 + - str + - Application identifier to use for the request + * - ``user`` + - 0..1 + - str + - User identifier/name to restrict the request to + +.. _sec-bundledplugins-appkey-datamodel-keyresponse: + +Key response +............ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``api_key`` + - 1 + - str + - the API key generated for the application + +.. _sec-bundledplugins-appkey-datamodel-decisionrequest: + +Decision request +................ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``decision`` + - 1 + - boolean + - ``True`` if the access request it to be granted, ``False`` otherwise + +.. _sec-bundledplugins-appkey-datamodel-listreponse: + +List response +............. + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``keys`` + - 1 + - list of :ref:`key list entries ` + - Keys registered in the system + * - ``pending`` + - 1 + - list of :ref:`pending list entries ` + - Currently pending authorization requests + +.. _sec-bundledplugins-appkey-datamodel-keylistentry: + +Key list entry +.............. + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``api_key`` + - 1 + - str + - API key + * - ``app_id`` + - 1 + - str + - Application identifier + * - ``user_id`` + - 1 + - str + - User ID of the key's owner + +.. _sec-bundledplugins-appkey-datamodel-pendinglistentry: + +Pending list entry +.................. + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``app_id`` + - 1 + - str + - Application identifier + * - ``user_id`` + - 0..1 + - str + - optional: User ID of user who can grant or deny request + * - ``user_token`` + - 1 + - str + - Token to grant or deny request + +.. _sec-bundledplugins-appkeys-jsclientlib: + +JavaScript Client Library +------------------------- + +.. js:function:: OctoPrintClient.plugins.appkeys.getKeys(opts) + + Retrieves registered keys and pending requests for the current user. + + See :ref:`Fetch list of existing application keys ` for more details. + + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. js:function:: OctoPrintClient.plugins.appkeys.getAllKeys(opts) + + Retrieves registered keys and pending requests for all users. + + Needs administrator rights. + + See :ref:`Fetch list of existing application keys ` for more details. + + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. js:function:: OctoPrintClient.plugins.appkeys.generateKey(app, opts) + + Generates a key for the given ``app`` and the current user. + + See :ref:`Issue an application key command ` for details. + + :param string app: Application identifier + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. js:function:: OctoPrintClient.plugins.appkeys.revokeKey(key, opts) + + Revokes the given ``key``. The key must belong to the current user, or the current user must have administrator + rights. + + See :ref:`Issue an application key command ` for details. + + :param string key: Key to revoke + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. js:function:: OctoPrintClient.plugins.appkeys.decide(token, decision, opts) + + Decides on an existing authorization request. + + See :ref:`Decide on existing request ` for more details. + + :param string token: User token for which to make the decision, as pushed to the client via the socket. + :param boolean decision: Whether to grant access (``true``) or not (``false``). + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. js:function:: OctoPrintClient.plugins.appkeys.probe(opts) + + Probes for workflow support. + + See :ref:`Probe for workflow support ` for more details. + + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. js:function:: OctoPrintClient.plugins.appkeys.request(app, opts) + + Starts a new authorization request for the provided ``app`` identifier. + + See :ref:`Start authorization process ` for more details. + + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. js:function:: OctoPrintClient.plugins.appkeys.requestForUser(app, user, opts) + + Starts a new authorization request for the provided ``app`` and ``user`` identifiers. + + See :ref:`Start authorization process ` for more details. + + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. js:function:: OctoPrintClient.plugins.appkeys.checkDecision(token, opts) + + Polls for a decision on an existing authorization request identified by ``token``. + + See :ref:`Poll for decision on existing request ` for more details. + + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. js:function:: OctoPrintClient.plugins.appkeys.authenticate(app, user, opts) + + Convenience function that probes for support, issues a request and then automatically starts polling for a decision + on the returned polling endpoint every 1s, until either a positive or negative decision is returned. On success the + returned promise is resolved with the generated API key as argument. If anything goes wrong or there is no support + for the workflow, the promise is rejected. + + **Example usage** + + .. sourcecode:: javascript + + OctoPrint.plugins.appkeys.authenticate("My App", "some_user") + .done(function(api_key) { + console.log("Got our API key:", api_key); + }) + .fail(function() { + console.log("No API key for us"); + }) + + :param string app: Application identifier + :param string user: Optional user identifier + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. _sec-bundledplugins-appkeys-sourcecode: + +Source code +----------- + +The source of the Application Keys plugin is bundled with OctoPrint and can be found in +its source repository under ``src/octoprint/plugins/appkeys``. diff --git a/docs/bundledplugins/backup.rst b/docs/bundledplugins/backup.rst new file mode 100644 index 0000000000..24be587e10 --- /dev/null +++ b/docs/bundledplugins/backup.rst @@ -0,0 +1,216 @@ +.. _sec-bundledplugins-backup: + +Backup Plugin +============= + +.. versionadded:: 1.3.10 + +The OctoPrint Backup Plugin comes bundled with OctoPrint (starting with 1.3.10). + +It allows the creation and restoration [#1]_ of backups of OctoPrint's settings, data and installed plugins [#2]_. + +This allows easy migration +to newly setup instances as well as making regular backups to prevent data loss. + +.. _fig-bundledplugins-backup-settings: +.. figure:: ../images/bundledplugins-backup-settings.png + :align: center + :alt: OctoPrint Backup Plugin + + The plugin's settings panel with existing backups, the backup creation and restore sections. + +As long as plugins adhere to the standard of storing their data and settings in OctoPrint's plugin data folders, their +data will be part of the backup. Note that the backups made by the Backup Plugin will *not* be part of any backups - +you'll need to persist the resulting zip files yourself! + +.. _sec-bundledplugins-backup-configuration: + +Configuring the plugin +---------------------- + +The plugin supports the following configuration keys: + + * ``restore_unsupported``: If the system you are installing OctoPrint on doesn't support restoring backups or you + want to disable that feature for other reasons, set this to `true`. Alternatively you can also set the + environment variable `OCTOPRINT_BACKUP_RESTORE_UNSUPPORTED` to `true`. OctoPrint will then disable the restore + functionality. Under normal circumstances you should not have to touch this setting (OctoPrint will do its + best to autodetect whether it's able to perform restores), thus it is not exposed in the Settings dialog. + +.. _sec-bundledplugins-backup-cli: + +Command line usage +------------------ + +The Backup Plugin implements a command line interface that allows creation and restoration of backups. +It adds two new commands, ``backup:backup`` and ``backup:restore``. + +.. code-block:: none + + $ octoprint plugins backup:backup --help + Initializing settings & plugin subsystem... + Usage: octoprint plugins backup:backup [OPTIONS] + + Creates a new backup. + + Options: + --exclude TEXT Identifiers of data folders to exclude, e.g. 'uploads' to + exclude uploads or 'timelapse' to exclude timelapses. + --help Show this message and exit. + + $ octoprint plugins backup:restore --help + Initializing settings & plugin subsystem... + Usage: octoprint plugins backup:restore [OPTIONS] PATH + + Restores an existing backup from the backup zip provided as argument. + + OctoPrint does not need to run for this to proceed. + + Options: + --help Show this message and exit. + +.. note:: + + The ``backup:backup`` command can be useful in combination with a cronjob to create backups in regular intervals. + +.. _sec-bundledplugins-backup-events: + +Events +------ + +*Events will not be triggered by CLI operations.* + +plugin_backup_backup_created + A new backup was created. On the push socket only available with a valid login session with ``Backup Access`` + permission. + + Payload: + + * ``name``: the name of the backup + * ``path``: the path to the backup + * ``excludes``: the list of parts excluded from the backup + + .. versionadded:: 1.5.0 + +.. _sec-bundledplugins-backup-hooks: + +Hooks +----- + +.. _sec-bundledplugins-backup-hooks-excludes: + +octoprint.plugin.backup.additional_excludes ++++++++++++++++++++++++++++++++++++++++++++ + +.. py:function:: additional_excludes_hook(excludes, *args, **kwargs) + + .. versionadded:: 1.5.0 + + Use this to provide additional paths on your plugin's data folder to exclude from the backup. Your handler also + get a list of currently excluded sub paths in the base folder, so you can react to them. E.g. exclude things + in your data folder that relate to uploaded GCODE files if `uploads` is excluded, or exclude things that relate + to timelapses if `timelapse` is excluded. + + Expects a list of additional paths relative to your plugin's data folder. If you return a single `.`, your whole + plugin's data folder will be excluded from the backup. + + **Example 1** + + The following example plugin will create two files ``foo.txt`` and ``bar.txt`` in its data folder, but flag + ``foo.txt`` as not to be backed up. + + .. code-block:: python + + # -*- coding: utf-8 -*- + from __future__ import absolute_import, division, print_function, unicode_literals + + import octoprint.plugin + + import os + import io + + class BackupExcludeTestPlugin(octoprint.plugin.OctoPrintPlugin): + def initialize(self): + with io.open(os.path.join(self.get_plugin_data_folder(), "foo.txt"), "w", encoding="utf-8") as f: + f.write("Hello\n") + with io.open(os.path.join(self.get_plugin_data_folder(), "bar.txt"), "w", encoding="utf-8") as f: + f.write("Hello\n") + + def additional_excludes_hook(self, excludes, *args, **kwargs): + return ["foo.txt"] + + __plugin_implementation__ = BackupExcludeTestPlugin() + __plugin_hooks__ = { + "octoprint.plugin.backup.additional_excludes": __plugin_implementation__.additional_excludes_hook + } + + **Example 2** + + In this example the plugin will create a file ``foo.txt`` in its data folder and flag its whole data folder as excluded from the + backup if uploaded GCODE files are also excluded: + + .. code-block:: python + + # -*- coding: utf-8 -*- + from __future__ import absolute_import, division, print_function, unicode_literals + + import octoprint.plugin + + import os + import io + + class BackupExcludeTestPlugin(octoprint.plugin.OctoPrintPlugin): + def initialize(self): + with io.open(os.path.join(self.get_plugin_data_folder(), "foo.txt"), "w", encoding="utf-8") as f: + f.write("Hello\n") + + def additional_excludes_hook(self, excludes, *args, **kwargs): + if "uploads" in excludes: + return ["."] + return [] + + __plugin_implementation__ = BackupExcludeTestPlugin() + __plugin_hooks__ = { + "octoprint.plugin.backup.additional_excludes": __plugin_implementation__.additional_excludes_hook + } + + :param excludes list: A list of paths already flagged as excluded in the backup + :return: A list of paths to exclude, relative to your plugin's data folder + :rtype: list + +Helpers +------- + +.. _sec-bundledplugins-backup-helpers: + +The Backup plugin exports two helpers that can be used by other plugins or internal methods +from within OctoPrint, without going via the API. + +.. _sec-bundledplugins-backup-helpers-create_backup: + +create_backup ++++++++++++++ + +.. autofunction:: octoprint.plugins.backup.BackupPlugin.create_backup_helper + + +.. _sec-bundledplugins-backup-helpers-delete_backup: + +delete_backup ++++++++++++++ + +.. autofunction:: octoprint.plugins.backup.BackupPlugin.delete_backup_helper + + +.. _sec-bundledplugins-backup-sourcecode: + +Source code +----------- + +The source of the Backup plugin is bundled with OctoPrint and can be found in +its source repository under ``src/octoprint/plugins/backup``. + +.. [#1] Note that restore is currently unavailable on OctoPrint servers running on Windows. Additionally they may be + disabled through a config flag or an environment variable as documented :ref:`here `. +.. [#2] Note that only those plugins that are available on `OctoPrint's official plugin repository `_ + can be automatically restored. If you have plugins installed that are not available on there you'll get their + names and - if available - homepage URL displayed after restore in order to be able to manually reinstall them. diff --git a/docs/bundledplugins/cura.rst b/docs/bundledplugins/cura.rst deleted file mode 100644 index aac96397f2..0000000000 --- a/docs/bundledplugins/cura.rst +++ /dev/null @@ -1,138 +0,0 @@ -.. _sec-bundledplugins_cura: - -Cura -==== - -The Cura Plugin allows slicing of STL files uploaded to OctoPrint directly via -the `CuraEngine `_ **up to and -including version 15.04.x** and supersedes the slicing support integrated into -OctoPrint so far. It comes bundled with OctoPrint starting with version 1.2.0. - -.. note:: - - Versions of CuraEngine later than 15.04.x have changed their calling - parameters in such a way that the current implementation of OctoPrint's Cura plugin - is not compatible to it. For this reason, please use only CuraEngine versions up to - and including 15.04 for now, as available in the ``legacy`` branch of the CuraEngine - repository on Github. - -The plugin offers a settings module that allows configuring the path to the -CuraEngine executable to use, as well as importing and managing slicing -profiles to be used. Please note that the Cura Plugin will use the printer -parameters you configured within OctoPrint (meaning bed size and extruder -count and offsets) for slicing. - -.. _sec-bundledplugins-cura-firststeps: - -First Steps ------------ - -Before you can slice from within OctoPrint, you'll need to - - #. :ref:`Install CuraEngine ` - #. :ref:`Configure the path to CuraEngine within OctoPrint ` - #. :ref:`Export a slicing profile from Cura and import it within OctoPrint ` - -.. note:: - - OctoPi 0.12.0 and later ships with steps 1 and 2 already done, you only need to - supply one or more slicing profiles to get going :) - -.. _sec-bundledplugins-cura-installing: - -Installing CuraEngine ---------------------- - -You'll need a build of ``legacy`` branch of `CuraEngine `_ -in order to be able to use the Cura OctoPrint plugin. You can find the ``legacy`` branch -`here `__. - -If you previously used the `old variant of the Cura integration `_, -you probably still have a fully functional binary lying around in the -installation folder of the full install of Cura you used then -- just put the -path to that in the plugin settings. - -.. _sec-bundledplugins-cura-installing-raspbian: - -Compiling for Raspbian -++++++++++++++++++++++ - -.. note:: - - A binary of CuraEngine 15.04.06 precompiled on Raspbian Jessie Lite 2016-03-18 is available - `here `__. Don't forget to make it - executable after copying it to your preferred destination on your Pi - (suggestion: ``/usr/local/bin``) with ``chmod +x cura_engine``. Use at your - own risk. - -Raspbian Jessie -~~~~~~~~~~~~~~~ - -Building on Raspbian Jessie is as easy as:: - - sudo apt-get -y install gcc-4.7 g++-4.7 - git clone -b legacy https://github.com/Ultimaker/CuraEngine.git - cd CuraEngine - make - -After this has completed, you'll find your shiny new build of CuraEngine in -the `build` folder (full path for above example: -``~/CuraEngine/build/CuraEngine``). - -Raspbian Wheezy -~~~~~~~~~~~~~~~ - -You'll need to install a new version of gcc and g++ and patch CuraEngine's -Makefile (see `this post `_) -in order for the compilation to work on current Raspbian builds (e.g. OctoPi):: - - sudo apt-get -y install gcc-4.7 g++-4.7 - git clone -b legacy https://github.com/Ultimaker/CuraEngine.git - cd CuraEngine - wget http://bit.ly/curaengine_makefile_patch -O CuraEngine.patch - patch < CuraEngine.patch - make CXX=g++-4.7 - -After this has completed, you'll find your shiny new build of CuraEngine in -the `build` folder (full path for above example: -``~/CuraEngine/build/CuraEngine``). - -.. _sec-bundledplugins-cura-configuring: - -Configuring the plugin ----------------------- - -The Cura plugin needs to be configured with the full path to your copy of the -CuraEngine executable that it's supposed to use. You can do this either via -the Cura plugin settings dialog or by manually configuring the path to the -executable via ``config.yaml``, example: - -.. code-block:: yaml - - plugins: - cura: - cura_engine: /path/to/CuraEngine - -.. _sec-bundledplugins-cura-profiles: - -Using Cura Profiles -------------------- - -The Cura Plugin supports importing your existing profiles for Cura **up to and -including Cura 15.04.x**. Newer Cura releases (e.g. 15.06 or 2.x) use a different -internal format that will *not* work with the current version of the Cura Plugin. - -You can find downloads of Cura 15.04.x for Windows, Mac and Linux `on Ultimaker's download page `_. - -In order to export a slicing profile from the Cura desktop UI, open it, -set up your profile, then click on "File" and there on "Save Profile". You can -import the .ini-file this creates via the "Import Profile" button in the -Cura Settings within OctoPrint. - -.. _sec-bundledplugins-cura-sourcecode: - -Source code ------------ - -The source of the Cura plugin is bundled with OctoPrint and can be found in -its source repository under ``src/octoprint/plugins/cura``. diff --git a/docs/bundledplugins/discovery.rst b/docs/bundledplugins/discovery.rst index 1fa8acdd77..d4646e593b 100644 --- a/docs/bundledplugins/discovery.rst +++ b/docs/bundledplugins/discovery.rst @@ -3,18 +3,19 @@ Discovery Plugin ================ -The OctoPrint Discovery Plugin comes bundled with OctoPrint (starting with 1.2.0). +.. versionadded:: 1.2.0 -It allows discovery of the OctoPrint instances via SSDP/UPNP. If -`pybonjour `_ is installed OctoPrint -will additionally support discovery via ZeroConf, also known as Bonjour or Avahi. +The OctoPrint Discovery Plugin comes bundled with OctoPrint. + +It allows discovery of the OctoPrint instances via SSDP/UPNP and ZeroConf +(also known as Bonjour or Avahi). The SSDP/UPNP support allows OctoPrint to announce itself to machines on the same network running Microsoft Windows. You will be able to just double click on the OctoPrint instance icon in "Networks > Other Devices" in your Windows Explorer, which will take you directly to the web frontend. -The ZeroConf support allows OctoPrint to announce itself to Safari on MacOS X +The ZeroConf support allows OctoPrint to announce itself to Safari on macOS on the same network. Linux users should install `Avahi `_ and can then use one @@ -28,45 +29,6 @@ line) to scan for available instances. Various discovered OctoPrint instances in Windows Explorer -.. _sec-bundledplugins-discovery-firststeps: - -First Steps ------------ - -.. _sec-bundledplugins-discovery-firststeps-pybonjour: - -Installing pybonjour -++++++++++++++++++++ - -.. note:: - - OctoPi versions 0.12.0 and later already come with pybonjour installed and ready to go, - you don't need to perform these steps there. - -.. note:: - - Currently there are no releases for pybonjour available on the Python Package Index PyPI. The latest pybonjour - release is still available in the `Google Code Archive `_. - Since that URL is hilariously long though, a shortened version is provided with https://goo.gl/SxQZ06 and - used in the installation instructions below. - -In order for the Zeroconf discovery to work, the -`pybonjour package `_ needs to be available -to the Python installation running OctoPrint. - -It can be installed via ``pip``. Let's assume you installed OctoPrint manually -into some folder ``~/OctoPrint``. You executed ``python setup.py install`` within a -virtualenv in the same folder called ``venv``. In order to install ``pybonjour`` -so it will be available to OctoPrint you'll need to do the following:: - - venv/bin/pip install https://goo.gl/SxQZ06 - -**Linux users:** You'll need to install an additional dependency for this to work, the -libdnssd compatibility layer for libavahi. On Debian/Ubuntu that can be achieved with:: - - sudo apt-get install libavahi-compat-libdnssd-dev - - .. _sec-bundledplugins-discovery-configuration: Configuring the plugin @@ -74,6 +36,10 @@ Configuring the plugin The plugin supports the following configuration keys: + * ``addresses``: List of IP addresses for which to enable discovery, if unset all + addresses available on the host will be used + * ``interfaces``: List of network interfaces for which to enable discovery, if unset + all interfaces on the host will be used * ``publicPort``: Public port number OctoPrint is reachable under, optional, if not set the port OctoPrint itself was started under will be used * ``pathPrefix``: Path prefix OctoPrint is running under, optional, if not @@ -128,6 +94,8 @@ The following snippet is a valid configuration example for the discovery plugin plugins: discovery: + interfaces: + - eth0 publicPort: 443 useSsl: true zeroConf: @@ -150,8 +118,7 @@ Announced Services ZeroConf Service ``_http._tcp`` +++++++++++++++++++++++++++++++ -If :ref:`pybonjour ` is -correctly installed, OctoPrint will announce itself on the network via ZeroConf +OctoPrint will announce itself on the network via ZeroConf as service ``_http._tcp``, with the TXT record containing the standard fields. See also `this documentation of _http._tcp TXT records `_ @@ -162,8 +129,7 @@ for more information. ZeroConf Service ``_octoprint._tcp`` ++++++++++++++++++++++++++++++++++++ -If :ref:`pybonjour ` is -correctly installed, OctoPrint will announce itself on the network via ZeroConf +OctoPrint will announce itself on the network via ZeroConf as service ``_octoprint._tcp``. The TXT record may contain the following fields: * ``path``: path prefix to actual OctoPrint instance, inherited from ``_http._tcp`` diff --git a/docs/bundledplugins/errortracking.rst b/docs/bundledplugins/errortracking.rst new file mode 100644 index 0000000000..71f423430b --- /dev/null +++ b/docs/bundledplugins/errortracking.rst @@ -0,0 +1,42 @@ +.. _sec-bundledplugins-errortracking: + +Error Tracking Plugin +===================== + +.. versionadded:: 1.3.11 + +The Error Tracking plugin will cause any logged exceptions in the server and the browser interface to be sent to +OctoPrint's `Sentry instance `_. + +By enabling it you help to gather detailed information on the cause of bugs or other issues. This is especially +valuable on release candidates, which is why this plugin will also prompt you to enable error tracking if it detects +that you are subscribed to an RC release channel. + +By default, even when enabled it will only be active if you are running a released OctoPrint version, so either a stable +release or a release candidate. + +The Error Tracking plugin is using a Sentry instance kindly provided by `sentry.io `_. For information on their service +please refer to their `Security & Compliance documentation `_ +and their `Privacy Policy `_. + +The Error Tracking plugin has been bundled with OctoPrint since version 1.3.11. + +.. _sec-bundledplugins-errortracking-configuration: + +Configuring the plugin +---------------------- + +The plugin supports the following configuration keys: + + * ``enabled``: Whether to enable error tracking. Defaults to ``false``. + * ``enabled_unreleased``: Whether to also enable tracking on unreleased OctoPrint versions (anything not stable releases + or release candidates). Defaults to ``false``. + * ``unique_id``: Unique instance identifier, auto generated on first activation + +.. _sec-bundledplugins-errortracking-sourcecode: + +Source Code +----------- + +The source of the Error Tracking plugin is bundled with OctoPrint and can be +found in its source repository under ``src/octoprint/plugins/errortracking``. diff --git a/docs/bundledplugins/file_check.rst b/docs/bundledplugins/file_check.rst new file mode 100644 index 0000000000..7be2b3baf3 --- /dev/null +++ b/docs/bundledplugins/file_check.rst @@ -0,0 +1,9 @@ +.. _sec-bundledplugins-file_check: + +File Check +========== + +.. versionadded:: 1.4.1 + +The file check plugin - while considered bundled with OctoPrint - is a separate project on its own release +cycle and documented on `its Github repository `_. diff --git a/docs/bundledplugins/firmware_check.rst b/docs/bundledplugins/firmware_check.rst new file mode 100644 index 0000000000..e73a3b9ab0 --- /dev/null +++ b/docs/bundledplugins/firmware_check.rst @@ -0,0 +1,11 @@ +.. _sec-bundledplugins-firmware_check: + +Firmware Check +============== + +.. versionadded:: 1.3.7 + +The firmware check plugin - while considered bundled with OctoPrint - is a separate project on its own release +cycle and documented on `its Github repository `_. + +It was formerly called "Printer Safety Check" and directly bundled with OctoPrint. diff --git a/docs/bundledplugins/gcodeviewer.rst b/docs/bundledplugins/gcodeviewer.rst new file mode 100644 index 0000000000..b112a22c76 --- /dev/null +++ b/docs/bundledplugins/gcodeviewer.rst @@ -0,0 +1,38 @@ +.. _sec-bundledplugins-gcodeviewer: + +GCode Viewer Plugin +=================== + +.. versionchanged:: 1.4.1 + +The GCode Viewer plugin provides a GCode viewer based on `Alex Ustyantsev's work `_. + +.. _fig-bundledplugins-gcodeviewer-example: +.. figure:: ../images/bundledplugins-gcodeviewer-example.png + :align: center + :alt: OctoPrint GCode Viewer Plugin + + An example of the GCode Viewer in action. + +The viewer has been included in OctoPrint ever since the first releases back in 2013, however as of +OctoPrint 1.4.1 it has been extracted into its own bundled plugin. + +.. _sec-bundledplugins-gcodeviewer-configuration: + +Configuring the plugin +---------------------- + +The plugin supports the following configuration keys: + + * ``mobileSizeThreshold``: Whether to also enable tracking on unreleased OctoPrint versions (anything not stable releases + or release candidates). Defaults to ``false``. + * ``sizeThreshold``: Unique instance identifier, auto generated on first activation + * ``skipUntilThis``: If this string is provided the GCode Viewer will search for this string, and if found, skip all gcode up until this string. This can be used to skip prime nozzle gcode in the preview + +.. _sec-bundledplugins-gcodeviewer-sourcecode: + +Source Code +----------- + +The source of the GCode Viewer plugin is bundled with OctoPrint and can be +found in its source repository under ``src/octoprint/plugins/gcodeviewer``. diff --git a/docs/bundledplugins/index.rst b/docs/bundledplugins/index.rst index d5935a96ad..7b8f4ff654 100644 --- a/docs/bundledplugins/index.rst +++ b/docs/bundledplugins/index.rst @@ -7,7 +7,18 @@ Bundled Plugins .. toctree:: :maxdepth: 2 - cura.rst + action_command_notification.rst + action_command_prompt.rst + announcements.rst + tracking.rst + appkeys.rst + backup.rst discovery.rst + errortracking.rst + file_check.rst + firmware_check.rst + gcodeviewer.rst + logging.rst pluginmanager.rst softwareupdate.rst + virtual_printer.rst diff --git a/docs/bundledplugins/logging.rst b/docs/bundledplugins/logging.rst new file mode 100644 index 0000000000..86d925fb6a --- /dev/null +++ b/docs/bundledplugins/logging.rst @@ -0,0 +1,238 @@ +.. _sec-bundledplugins-logging: + +Logging +======= + +.. versionadded:: 1.3.7 + +The OctoPrint Logging plugin implements the log management functionality that was formerly part of the core application and adds features to +configure logging levels for sub modules through the included settings dialog. + +.. _fig-bundledplugins-logging-settings: +.. figure:: ../images/bundledplugins-logging-settings.png + :align: center + :alt: Logging plugin + + The settings dialog of the Logging plugin + +.. _sec-bundledplugins-logging-api: + +API +--- + +.. note:: + + All log file management operations require admin rights. + +.. _sec-bundledplugins-logging-api-list_logs: + +Retrieve a list of available log files +++++++++++++++++++++++++++++++++++++++ + +.. http:get:: /plugin/logging/logs + + Retrieve information regarding all log files currently available and regarding the disk space still available + in the system on the location the log files are being stored. + + Returns a :ref:`Logfile Retrieve response `. + + **Example** + + .. sourcecode:: http + + GET /plugin/logging/logs HTTP/1.1 + Host: example.com + X-Api-Key: abcdef... + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "files" : [ + { + "date" : 1393158814, + "name" : "octoprint.log", + "size" : 43712, + "refs": { + "resource": "http://example.com/plugin/logging/logs/octoprint.log", + "download": "http://example.com/downloads/logs/octoprint.log" + } + }, + { + "date" : 1392628936, + "name" : "octoprint.log.2014-02-17", + "size" : 13205, + "refs": { + "resource": "http://example.com/plugin/logging/logs/octoprint.log.2014-02-17", + "download": "http://example.com/downloads/logs/octoprint.log.2014-02-17" + } + }, + { + "date" : 1393158814, + "name" : "serial.log", + "size" : 1798419, + "refs": { + "resource": "http://example.com/plugin/logging/logs/serial.log", + "download": "http://example.com/downloads/logs/serial.log" + } + } + ], + "free": 12237201408 + } + + :statuscode 200: No error + :statuscode 403: If the given API token did not have admin rights associated with it + +.. _sec-bundledplugins-logging-api-delete_logs: + +Delete a specific logfile ++++++++++++++++++++++++++ + +.. http:delete:: /plugin/logging/logs/(path:filename) + + Delete the selected log file with name `filename`. + + Returns a :http:statuscode:`204` after successful deletion. + + **Example Request** + + .. sourcecode:: http + + DELETE /plugin/logging/logs/octoprint.log.2014-02-17 HTTP/1.1 + Host: example.com + X-Api-Key: abcdef... + + :param filename: The filename of the log file to delete + :statuscode 204: No error + :statuscode 403: If the given API token did not have admin rights associated with it + :statuscode 404: If the file was not found + +.. _sec-bundledplugins-logging-api-datamodel: + +Data model +++++++++++ + +.. _sec-bundledplugins-logging-api-datamodel-retrieveresponse: + +Logfile Retrieve Response +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``files`` + - 0..* + - Array of :ref:`File information items ` + - The list of requested files. Might be an empty list if no files are available + * - ``free`` + - 1 + - String + - The amount of disk space in bytes available in the local disk space (refers to OctoPrint's ``logs`` folder). + +.. _sec-bundledplugins-logging-api-datamodel-fileinfo: + +File information +~~~~~~~~~~~~~~~~ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``name`` + - 1 + - String + - The name of the file + * - ``size`` + - 1 + - Number + - The size of the file in bytes. + * - ``date`` + - 1 + - Unix timestamp + - The timestamp when this file was last modified. + * - ``refs`` + - 1 + - :ref:`References ` + - References relevant to this file + +.. _sec-bundledplugins-logging-api-datamodel-ref: + +References +~~~~~~~~~~ + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``resource`` + - 1 + - URL + - The resource that represents the file (e.g. for deleting) + * - ``download`` + - 1 + - URL + - The download URL for the file + +.. _sec-bundledplugins-logging-jsclientlib: + +JS Client Library +----------------- + +:mod:`OctoPrintClient.plugins.logging` +-------------------------------------- + +.. note:: + + All methods here require that the used API token or the existing browser session + has admin rights. + +.. js:function:: OctoPrintClient.plugins.logging.listLogs(opts) + + Retrieves a list of log files. + + See :ref:`Retrieve a list of available log files ` for details. + + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. js:function:: OctoPrintClient.plugins.logging.deleteLog(path, opts) + + Deletes the specified log ``path``. + + See :ref:`Delete a specific log file ` for details. + + :param string path: The path to the log file to delete + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. js:function:: OctoPrintClient.plugins.logging.downloadLog(path, opts) + + Downloads the specified log ``file``. + + See :js:func:`OctoPrint.download` for more details on the underlying library download mechanism. + + :param string path: The path to the log file to download + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. _sec-bundledplugins-logging-sourcecode: + +Source Code +----------- + +The source of the Logging plugin is bundled with OctoPrint and can be found in its source repository under ``src/octoprint/plugins/logging``. diff --git a/docs/bundledplugins/loginui.rst b/docs/bundledplugins/loginui.rst new file mode 100644 index 0000000000..4697cd7d36 --- /dev/null +++ b/docs/bundledplugins/loginui.rst @@ -0,0 +1,6 @@ +.. _sec-bundledplugins-loginui: + +Login UI +======== + +The Login UI plugin's functionality has been merged into OctoPrint's core as of OctoPrint 1.4.2. diff --git a/docs/bundledplugins/pluginmanager.rst b/docs/bundledplugins/pluginmanager.rst index dacb3f8ca9..82eb6f393e 100644 --- a/docs/bundledplugins/pluginmanager.rst +++ b/docs/bundledplugins/pluginmanager.rst @@ -3,8 +3,9 @@ Plugin Manager ============== -The OctoPrint Plugin Manager comes bundled with OctoPrint starting with -version 1.2.0. +.. versionadded:: 1.2.0 + +The OctoPrint Plugin Manager comes bundled with OctoPrint. It allows management of installed plugins (listing, enabling, disabling and uninstalling) and installing new plugins from the official @@ -82,6 +83,45 @@ under Settings > Plugin Manager, or by directly editing ``config.yaml``: - hidden - plugins +.. _sec-bundledplugins-pluginmanager-events: + +Events +------ + +plugin_pluginmanager_install_plugin + A plugin was installed. + + Payload: + + * ``id``: the identifier of the installed plugin + * ``version``: the version of the installed plugin + * ``source``: source from which the plugin was installed, can be an URL or a path in the local file system + * ``source_type``: type of source from which the plugin was installed, can be ``url`` or ``path`` + +plugin_pluginmanager_uninstall_plugin + A plugin was uninstalled. + + Payload: + + * ``id``: the identifier of the uninstalled plugin + * ``version``: the version of the uninstalled plugin + +plugin_pluginmanager_enable_plugin + A plugin was enabled. + + Payload: + + * ``id``: the identifier of the enabled plugin + * ``version``: the version of the enabled plugin + +plugin_pluginmanager_disabled_plugin + A plugin was disabled. + + Payload: + + * ``id``: the identifier of the disabled plugin + * ``version``: the version of the disabled plugin + .. _sec-bundledplugins-pluginmanager-hooks: Hooks diff --git a/docs/bundledplugins/softwareupdate.rst b/docs/bundledplugins/softwareupdate.rst index b68a6751b4..574770f2b1 100644 --- a/docs/bundledplugins/softwareupdate.rst +++ b/docs/bundledplugins/softwareupdate.rst @@ -3,10 +3,14 @@ Software Update Plugin ====================== +.. versionadded:: 1.2.0 + The Software Update Plugin allows receiving notifications about new releases of OctoPrint or installed plugins which registered with it and -- if properly configured -- also applying the found updates. +It comes bundled with OctoPrint. + .. _sec-bundledplugins-softwareupdate-firststeps: First Steps @@ -24,7 +28,7 @@ Settings Dialog, navigate to the Software Update section therein and once you ar wrench icon in the upper right corner. .. _fig-bundledplugins-softwareupdate-plugin-configuration: -.. figure:: ../images/bundledplugins-softwareupdate-plugin-configuration.png +.. figure:: ../images/bundledplugins-softwareupdate-configuration.png :align: center :alt: Software Update plugin configuration dialog @@ -34,21 +38,25 @@ There you can adjust the following settings: * **OctoPrint version tracking**: Whether you want to track OctoPrint *releases* or every *commit*. Usually you want to select "Release" here which is also the default, unless you are a developer. - * **OctoPrint Release Channel** (if tracking releases): The release channel of OctoPrint to track for updates. If you only want stable versions, - select "Stable" here which is also the default. "Maintenance RCs" will also allow you to update to maintenance release - candidates, "Devel RCs" will also allow you to update to development release candidates. If in doubt, leave it at - "Stable". `Read more about Release Channels here `_. - * **OctoPrint checkout folder** (if tracking git commits): This must be the path to OctoPrint's git checkout folder - (``/home/pi/OctoPrint`` for OctoPi or `manual installs following the Raspberry Pi setup guide `_). + * **Tracked branch** (if tracking is set to "Github Commit"): The branch that will be tracked if you set version tracking to "Github Commit". + * **OctoPrint ``pip`` target** (if tracking is set to "Release" or "Github Commit"): The argument that will be provided to ``pip`` when updating OctoPrint. + Usually you don't want to change this from its default value of ``https://github.com/OctoPrint/OctoPrint/archive/{target_version}.zip``. + * **OctoPrint checkout folder** (if tracking is set to "Local checkout"): This must be the path to OctoPrint's git checkout folder + (``/home/pi/OctoPrint`` for OctoPi or `manual installs following the Raspberry Pi setup guide `_). Note that since OctoPrint 1.3.6 you will no longer need to set this to be able to update to releases, only if you want to be able to update against some bleeding edge git branch. + * **Enable ``pip`` update checks**: Whether to have OctoPrint automatically check for updates of + the ``pip`` tool that is used for updating most components. * **Version cache TTL**: The "time to live" of the cache OctoPrint will use to temporarily persist the version information for the various components registered with the plugin, so that they don't have to be queried from the internet every time you load the page. Defaults to 24h, you usually shouldn't need to change that value. + * **Show notifications to users**: Whether to display update notifications (without "Update now" button) to users that cannot + apply updates. + * **Minimum free disk space**: The minimum amount of free disk space needed in order to allow software updates to be run. -More settings are available by :ref:`editing the correspondi.. _section in config.yaml `. +More settings are available by :ref:`editing the corresponding section in config.yaml `. -That restart commands for OctoPrint and the whole server can be configured under Settings > Server. +Restart commands for OctoPrint and the whole server can be configured under Settings > Server. .. _sec-bundledplugins-softwareupdate-cli: @@ -56,9 +64,9 @@ Command line usage ------------------ The functionality of the Software Update Plugin is also available on OctoPrint's command line interface under the -``plugins`` sub command. It's is possible to check for updates via ``octoprint plugins softwareupdate:check`` -and to apply available updates via ``octoprint plugins softwareupdate:update``. Please the corresponding -``--help`` pages on details: +``plugins`` sub command. It's possible to check for updates via ``octoprint plugins softwareupdate:check`` +and to apply available updates via ``octoprint plugins softwareupdate:update``. Please see the corresponding +``--help`` pages for details: .. code-block:: none @@ -119,33 +127,68 @@ Configuring the Plugin plugins: softwareupdate: - # the time-to-live of the version cache, in minutes - cache_ttl: 60 - # configured version check and update methods checks: # "octoprint" is reserved for OctoPrint octoprint: - # this defines an version check that will check against releases + # this defines a version check that will check against releases # published on OctoPrint's Github repository and pip as update method # against the release archives on Github - this is the default type: github_release user: foosel repo: OctoPrint method: pip - pip: 'https://github.com/foosel/OctoPrint/archive/{target_version}.zip' + pip: 'https://github.com/OctoPrint/OctoPrint/archive/{target_version}.zip' - # further checks may be define here + # further checks may be defined here # pip command, if another one than the automatically detected one should be # used - should normally NOT be necessary and hence set pip_command: /path/to/pip + # the time-to-live of the version cache, in minutes, defaults to 24h + cache_ttl: 1440 + + # whether to show update notifications to users that cannot apply updates + notify_users: true + + # whether to ignore the system throttled state reported by the pisupport plugin and + # allow updating even when the system is not running stable - really not recommended + ignore_throttled: false + + # minimum free storage in MB for updates to be enabled + minimum_free_storage: 150 + + # URL from which to fetch check overlays + check_overlay_url: https://plugins.octoprint.org/update_check_overlay.json + + # time to live of the overlay cache, defaults to 6h + check_overlay_ttl: 360 + + # global credentials to provide to version checks + credentials: + + # GitHub API token to use for the github_release and github_commit version checks. + # Helpful if you regularly run into rate limit issues with the GitHub API using + # the default anonymous access. Use a personal access token: + # https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token + # Unset by default + github: + + # Bitbucket user name and password, used by the bitbucket_commit version check if + # provided, but only if the check doesn't specify credentials on its own. + # Unset by default + bitbucket_user: + bitbucket_password: + .. _sec-bundledplugins-softwareupdate-configuration-versionchecks: Version checks ++++++++++++++ +Version check types are configured through the ``type`` parameter. The following +types are currently recognized: + * ``github_release``: Checks against releases published on Github. Additional config parameters: @@ -153,10 +196,33 @@ Version checks * ``repo``: (mandatory) Github repository to check * ``prerelease``: ``True`` or ``False``, default ``False``, set to ``True`` to also include releases on Github marked as prerelease. - * ``release_branch``: Branch name to check against ``target_comittish`` - field in Github release data - release will only be included if the - values match. Defaults to being unset, in which case no match will - be performed. + * ``prerelease_branches``: Prerelease channel definitions, optional. List of: + + * ``branch``: Branch associated with the channel, acts as ID + * ``name``: Human readable name of the release channel + * ``commitish``: List of values to check against ``target_commitish`` + field in Github release data - release will only be included if the + values match. Defaults to being unset, in which case the ``branch`` + will be matched. + + .. versionadded:: 1.2.16 + * ``stable_branch``: Stable channel definition, optional. Structure: + + * ``branch``: Branch associated with the channel, acts as ID + * ``name``: Human readable name of the release channel + * ``commitish``: List of values to check against ``target_commitish`` + field in Github release data - release will only be included if the + values match. Defaults to being unset, in which case the ``branch`` + will be matched. + + .. versionadded:: 1.2.16 + * ``prerelease_channel``: Release channel to limit updates to. If set only + those releases will be included if their ``target_commitish`` matches + the ones associated with the release channel identified by this, either + included in ``prerelease_channels`` or the ``stable_channel``. Only + taken into account if ``prerelease`` is ``true``. + .. versionadded:: 1.2.16 + * ``release_compare``: Method to use to compare between current version information and release versions on Github. One of ``python`` (version comparison using ``pkg_resources.parse_version``, newer version detected @@ -190,12 +256,42 @@ Version checks ``api_user`` to be set. **Important**: Never use your actual Bitbucket login password. Generate a new app password. App passwords are user specific on Bitbucket. + .. versionadded:: 1.3.5 + * ``git_commit``: Checks a local git repository for new commits on its configured remote. Additional config parameters: * ``checkout_folder``: (mandatory) The full path to the folder with a valid git repository to check. + * ``pypi_release``: Checks `pypi.org `_ for new releases of a specified package. Additional + config parameters: + + * ``package``: (mandatory) Name of the package which to check. + + .. versionadded:: 1.4.0 + + * ``httpheader``: Checks an HTTP header on a defined URL for changes. This can be used for easy checks + against things like ``ETag`` or ``Last-Modified`` headers. Additional + config parameters: + + * ``header_url`` or ``url``: (mandatory) URL to check. ``url`` can be used to avoid duplication in case of updater + methods such as ``single_file_plugin``. + * ``header_name``: (mandatory) HTTP header to check, case-insensitive, e.g. ``ETag`` or ``Last-Modified``. + * ``header_method``: HTTP request method to use for the check, defaults to ``HEAD``. + * ``header_prefix``: Prefix to use for the obtained value in the version display. If not provided ``header_name`` + will be used. If set to an empty string, no prefix will be added. + + .. versionadded:: 1.4.1 + + * ``jsondata``: Checks the provided JSON endpoint for changes. The JSON endpoint must return an object with the + property ``version``, which should contain the latest version, e.g. ``{"version":"1.2.3"}``. Additional + config parameters: + + * ``jsondata``: (mandatory) URL from which to fetch the JSON data + + .. versionadded:: 1.4.1 + * ``command_line``: Uses a provided script to determine whether an update is available. Additional config parameters: @@ -209,29 +305,71 @@ Version checks :ref:`hook `. Additional config parameters: - * ``python_checker``: (mandatory) A python callable which returns version + * ``python_checker``: (mandatory) A Python callable which returns version information and whether the current version is up-to-date or not, see below for details. + * ``always_current``: Always reports that no update is necessary. Useful for debugging + software update mechanisms during development. Additional config parameters: + + * ``current_version``: Version to report for both local and remote version. + + .. versionadded:: 1.3.7 + + * ``never_current``: Always reports that an update is necessary. Useful for debugging + software update mechanisms during development. Additional config parameters: + + * ``local_version``: Current local version. Defaults to ``1.0.0``. + * ``remote_version``: Remote version to offer update to. Defaults to ``1.0.1``. + + .. versionadded:: 1.3.7 + .. _sec-bundledplugins-softwareupdate-configuration-updatemethods: Update methods ++++++++++++++ - * ``pip``: An URL to provide to ``pip install`` in order to perform the - update. May contain a placeholder ``{target}`` which will be the most - recent version specifier as retrieved from the update check. - * ``update_script``: A script to execute in order to perform the update. May - contain placeholders ``{target}`` (for the most recent version specified - as retrieved from the update check), ``{branch}`` for the branch to switch - to to access the release, ``{folder}`` for the working directory - of the script and ``{python}`` for the python executable OctoPrint is - running under. The working directory must be specified either by an - ``update_folder`` setting or if the ``git_commit`` check is used its - ``checkout_folder`` setting. - * ``python_updater``: Can only be specified by plugins through the - :ref:`hook `. A python callable - which performs the update, see below for details. +Update methods are specified via the ``method`` parameter. Some update methods are assigned implicitly +through the presence of their mandatory configuration parameters. The following methods are currently +supported: + + * ``pip``: Update by ``pip install``ing the supplied URL. May contain a + placeholder ``{target}`` which will be the most recent version specifier as retrieved from the update check. + Additional config parameters: + + * ``pip``: The URL to use for installing. Presence implies ``method: pip``. + + * ``single_file_plugin``: Update a single file plugin by re-downloading it from a configured URL. + Additional config parameters: + + * ``url``: (mandatory) The URL from which to install the single file plugin. Must be a single self contained + python file. + + * ``update_script``: Update by executing a script. + Additional config parameters: + + * ``update_script``: (mandatory) The path of the script to run. May + contain placeholders ``{target}`` (for the most recent version specified + as retrieved from the update check), ``{branch}`` for the branch to switch + to to access the release, ``{folder}`` for the working directory + of the script and ``{python}`` for the python executable OctoPrint is + running under. Presence implies ``method: update_script``. + * ``update_folder`` or ``checkout_folder``: (mandatory) The working directory. + ``checkout_folder`` can be used to avoid duplication in case of check + types such as ``git_commit``. + + * ``python_updater``: Update by executing a custom python callable. + Additional config parameters: + + * ``python_updater``: (mandatory) Can only be specified by plugins through the + :ref:`hook `. A Python callable + which performs the update, see below for details. Presence implies ``method: python_updater``. + + * ``sleep_a_bit``: Does nothing but block for a configurable ``duration`` and log + a countdown in the meantime. Useful for debugging software update mechanisms + during development. + + .. versionadded:: 1.3.7 .. note:: @@ -255,7 +393,7 @@ Update methods user: foosel repo: OctoPrint method: pip - pip: 'https://github.com/foosel/OctoPrint/archive/{target_version}.zip' + pip: 'https://github.com/OctoPrint/OctoPrint/archive/{target_version}.zip' update_script: '{python} "/path/to/octoprint-update.py" --python="{python}" "{folder}" "{target}"' checkout_folder: /path/to/octoprint/checkout/folder @@ -296,7 +434,71 @@ tracked: repo: OctoPrint-SomePlugin pip: 'https://github.com/someUser/OctoPrint-SomePlugin/archive/{target}.zip' -The same, but tracking all commits pushed to branch ``devel`` (thus allowing +The same, but declaring three release channels "Stable", "Maintenance RCs" (tagged on ``rc/maintenance`` or ``master``, +id ``rc/maintenance``) and "Devel RCs" (tagged on ``rc/maintenance``, ``rc/devel`` or ``master``, id ``rc/devel``), +but with "Stable" active: + +.. code-block:: yaml + + plugins: + softwareupdate: + checks: + some_plugin: + type: github_release + user: someUser + repo: OctoPrint-SomePlugin + stable_branch: + name: Stable + branch: master + commitish: + - master + prerelease_branches: + - name: Maintenance RCs + branch: rc/maintenance + commitish: + - rc/maintenance + - master + - name: Devel RCs + branch: rc/devel + commitish: + - rc/devel + - rc/maintenance + - master + pip: 'https://github.com/someUser/OctoPrint-SomePlugin/archive/{target}.zip' + +And now with "Maintenance RCs" active (note the ``prerelease`` and ``prerelease_channel`` settings): + +.. code-block:: yaml + + plugins: + softwareupdate: + checks: + some_plugin: + type: github_release + user: someUser + repo: OctoPrint-SomePlugin + stable_branch: + name: Stable + branch: master + commitish: + - master + prerelease_branches: + - name: Maintenance RCs + branch: rc/maintenance + commitish: + - rc/maintenance + - master + - name: Devel RCs + branch: rc/devel + commitish: + - rc/devel + - rc/maintenance + - master + prerelease: True + prerelease_channel: rc/maintenance + pip: 'https://github.com/someUser/OctoPrint-SomePlugin/archive/{target}.zip' + +The same plugin again, but tracking all commits pushed to branch ``devel`` (thus allowing "bleeding edge" updates): .. code-block:: yaml @@ -311,6 +513,82 @@ The same, but tracking all commits pushed to branch ``devel`` (thus allowing branch: devel pip: 'https://github.com/someUser/OctoPrint-SomePlugin/archive/{target}.zip' +Single file plugin hosted in a gist ``https://gist.github.com/someUser/somegist`` and updated whenever there are changes: + +.. code-block:: yaml + + plugins: + softwareupdate: + checks: + some_plugin: + type: httpheader + header_name: ETag + url: 'https://gist.github.com/someUser/somegist/raw/some_plugin.py' + method: single_file_plugin + +The same but updated when a ``version.json`` hosted alongside gets updated with a new version can be found at + +.. code-block:: yaml + + plugins: + softwareupdate: + checks: + some_plugin: + type: jsondata + jsondata: 'https://gist.github.com/someUser/somegist/raw/version.json' + url: 'https://gist.github.com/someUser/somegist/raw/some_plugin.py' + method: single_file_plugin + +Note that for gist hosted single file plugins, you need to use the "Raw" install link but should remove the +commit identifier. E.g. ``https://gist.githubusercontent.com///raw/my_plugin.py`` instead of +``https://gist.githubusercontent.com///raw//my_plugin.py``. Note that these URLs will +be cached by Github for a bit, so an update will not be immediately picked up. + +.. _sec-bundledplugins-softwareupdate-configuration-credentials: + +Global credentials +++++++++++++++++++ + +.. versionadded:: 1.5.0 + +Starting with OctoPrint 1.5.0, the Software Update Plugin supports supplyting a GitHub +API token to use for the ``github_release`` and ``github_commit`` version check types, +to work around possible rate limit problems if a lot of checks are to be made from a single +external IP. You may create a `personal access token `_ +and configure that as ``plugins.softwareupdate.credentials.github`` via +:ref:`config.yaml ` in order to get a higher rate limit than with purely anonymous access. + +Additionally, the username and password to use with the ``bitbucket_commit`` version check +type may also be configured via ``plugins.softwareupdate.credentials.bitbucket_user`` and +``plugins.softwareupdate.credentials.bitbucket_password`` respectively. + +None of these configuration options are currently exposed on the UI and can only be used +via :ref:`config.yaml ` or the +:ref:`config command line interface `. + +.. _sec-bundledplugins-softwareupdate-events: + +Events +------ + +plugin_softwareupdate_update_succeeded + An update succeeded. + + Payload: + + * ``target``: update target + * ``from_version``: version from which was updated + * ``to_version``: version to which was updated + +plugin_softwareupdate_update_failed + An update failed. + + Payload: + + * ``target``: update target + * ``from_version``: version from which was updated + * ``to_version``: version to which was updated + .. _sec-bundledplugins-softwareupdate-hooks: Hooks @@ -348,8 +626,8 @@ octoprint.plugin.softwareupdate.check_config .. code-block:: python - # coding=utf-8 - from __future__ import absolute_import + # -*- coding: utf-8 -*- + from __future__ import absolute_import, unicode_literals def get_update_information(*args, **kwargs): return dict( diff --git a/docs/bundledplugins/tracking.rst b/docs/bundledplugins/tracking.rst new file mode 100644 index 0000000000..0c5655ed2e --- /dev/null +++ b/docs/bundledplugins/tracking.rst @@ -0,0 +1,47 @@ +.. _sec-bundledplugins-tracking: + +Anonymous Usage Tracking Plugin +=============================== + +.. versionadded:: 1.3.10 + +The Anonymous Usage Tracking plugin provides valuable insights into how many instances running what versions of +OctoPrint are out there, whether they are successfully completing print jobs and various other metrics. + +By enabling it you help to identify problems with new releases and release candidates early on, and to better tailor +OctoPrint's future development to actual use. + +For details on what gets tracked, please refer to `tracking.octoprint.org `_ +and also the `Privacy Policy at tracking.octoprint.org `_. + +The Anonymous Usage Tracking plugin has been bundled with OctoPrint since version 1.3.10. + +.. _sec-bundledplugins-tracking-configuration: + +Configuring the plugin +---------------------- + +The plugin supports the following configuration keys: + + * ``enabled``: Whether to enable usage tracking. Defaults to ``false``. + * ``unique_id``: Unique instance identifier, auto generated on first activation + * ``server``: The tracking server to track against. Defaults to a tracking endpoint on ``https://tracking.octoprint.org``. + * ``ping``: How often to send a ``ping`` tracking event, in seconds. Defaults to a 15min interval. + * ``events``: Granular configuration of enabled tracking events. All default to ``true``. + + * ``startup``: Whether to track startup/shutdown events + * ``printjob``: Whether to track print job related events (start, completion, cancel, ...) + * ``commerror``: Whether to track communication errors with the printer + * ``plugin``: Whether to track plugin related events (install, uninstall, ...) + * ``update``: Whether to track update related events (update successful or not, ...) + * ``printer``: Whether to track printer related events (connected, firmware, ...) + * ``printer_safety_check``: Whether to track warnings of the Printer Safety Check plugin + * ``throttled``: Whether to track throttle events detected on the underlying system + +.. _sec-bundledplugins-tracking-sourcecode: + +Source Code +----------- + +The source of the Anonymous Usage Tracking plugin is bundled with OctoPrint and can be +found in its source repository under ``src/octoprint/plugins/tracking``. diff --git a/docs/bundledplugins/virtual_printer.rst b/docs/bundledplugins/virtual_printer.rst new file mode 100644 index 0000000000..bc1ba147af --- /dev/null +++ b/docs/bundledplugins/virtual_printer.rst @@ -0,0 +1,28 @@ +.. _sec-bundledplugins-virtual_printer: + +Virtual Printer +=============== + +.. versionchanged:: 1.4.1 + +The Virtual Printer plugin provides a virtual printer to connect to during development. It is able to simulate various +firmware quirks, communication issues and can be heavily configured through ``config.yaml``. See +:ref:`the development documentation on details and usage `. + +The virtual printer has been included in OctoPrint ever since the first releases back in 2013, however as of +OctoPrint 1.4.1 it has finally been fully extracted into its own bundled plugin. + +.. _sec-bundledplugins-virtual_printer-configuration: + +Configuring the plugin +---------------------- + +Please refer to :ref:`the development documentation `. + +.. _sec-bundledplugins-virtual_printer-sourcecode: + +Source Code +----------- + +The source of the Virtual Printer plugin is bundled with OctoPrint and can be +found in its source repository under ``src/octoprint/plugins/virtual_printer``. diff --git a/docs/conf.py b/docs/conf.py index 3827b54faa..5d8864016d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + # # OctoPrint documentation build configuration file, created by # sphinx-quickstart on Mon Dec 02 17:08:50 2013. @@ -16,8 +18,8 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath('../src/')) -sys.path.append(os.path.abspath('sphinxext')) +sys.path.insert(0, os.path.abspath("../src/")) +sys.path.append(os.path.abspath("sphinxext")) import octoprint._version from datetime import date @@ -28,29 +30,46 @@ # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -needs_sphinx = '1.3' +needs_sphinx = "1.3" # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['codeblockext', 'onlineinclude', 'sphinx.ext.todo', 'sphinx.ext.autodoc', 'sphinxcontrib.httpdomain', - 'sphinx.ext.napoleon', 'sphinxcontrib.mermaid'] +extensions = [ + "codeblockext", + "onlineinclude", + "sphinx.ext.todo", + "sphinx.ext.autodoc", + "sphinxcontrib.httpdomain", + "sphinx.ext.napoleon", + "sphinxcontrib.mermaid", + "sphinx.ext.intersphinx", +] todo_include_todos = True +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "python2": ("https://docs.python.org/2", None), + "pyserial": ("https://pythonhosted.org/pyserial", None), +} # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'OctoPrint' -copyright = u'%d-%d, Gina Häußge' % (year_since, year_current) if year_current > year_since else u'%d, Gina Häußge' % year_since +project = "OctoPrint" +copyright = ( + "%d-%d, Gina Häußge" % (year_since, year_current) + if year_current > year_since + else "%d, Gina Häußge" % year_since +) # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -63,137 +82,139 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False numfig = True +mermaid_version = "" + # -- Options for HTML output --------------------------------------------------- -# on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org -on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +import sphinx_rtd_theme -if not on_rtd: # only import and set the theme if we're building docs locally - import sphinx_rtd_theme - html_theme = 'sphinx_rtd_theme' - html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +html_theme = "sphinx_rtd_theme" +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # The theme to use for HTML and HTML Help pages. See the documentation for # a list of built-in themes. -#html_theme = "sphinx_rtd_theme" +# html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +# html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the built-in static files, # so a file named "default.css" will overwrite the built-in "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] + def setup(app): - app.add_stylesheet("theme_overrides.css") + app.add_css_file("theme_overrides.css") + app.add_js_file("mermaid.min.js") + # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'OctoPrintdoc' +htmlhelp_basename = "OctoPrintDoc" # -- Options for LaTeX output -------------------------------------------------- -#latex_elements = { +# latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', @@ -202,47 +223,44 @@ def setup(app): # Additional stuff for the LaTeX preamble. #'preamble': '', -#} +# } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). -#latex_documents = [ -# ('index', 'OctoPrint.tex', u'OctoPrint Documentation', -# u'Gina Häußge', 'manual'), -#] +# latex_documents = [ +# ('index', 'OctoPrint.tex', 'OctoPrint Documentation', +# 'Gina Häußge', 'manual'), +# ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then top-level headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'octoprint', u'OctoPrint Documentation', - [u'Gina Häußge'], 1) -] +man_pages = [("index", "octoprint", "OctoPrint Documentation", ["Gina Häußge"], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -251,19 +269,25 @@ def setup(app): # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'OctoPrint', u'OctoPrint Documentation', - u'Gina Häußge', 'OctoPrint', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "OctoPrint", + "OctoPrint Documentation", + "Gina Häußge", + "OctoPrint", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/docs/configuration/cli.rst b/docs/configuration/cli.rst new file mode 100644 index 0000000000..b24d13865f --- /dev/null +++ b/docs/configuration/cli.rst @@ -0,0 +1,110 @@ +.. _sec-configuration-cli: + +CLI +=== + +.. versionadded:: 1.3.0 + +OctoPrint provides a basic command line interface for manipulation of :ref:`config.yaml `: + +.. code-block:: + + $ octoprint config --help + Usage: octoprint config [OPTIONS] COMMAND [ARGS]... + + Basic config manipulation. + + Options: + --help Show this message and exit. + + Commands: + append_value Appends value to list behind config path. + effective Retrieves the full effective config. + get Retrieves value from config path. + insert_value Inserts value at index of list behind config key. + remove Removes a config path. + remove_value Removes value from list at config path. + set Sets a config path to the provided value. + +.. code-block:: + + $ octoprint config append_value --help + Usage: octoprint config append_value [OPTIONS] PATH VALUE + + Appends value to list behind config path. + + Options: + --json + --help Show this message and exit. + +.. code-block:: + + $ octoprint config effective --help + Usage: octoprint config effective [OPTIONS] + + Retrieves the full effective config. + + Options: + --json Output value formatted as JSON + --yaml Output value formatted as YAML + --raw Output value as raw string representation + --help Show this message and exit. + +.. code-block:: + + $ octoprint config get --help + Usage: octoprint config get [OPTIONS] PATH + + Retrieves value from config path. + + Options: + --json Output value formatted as JSON + --yaml Output value formatted as YAML + --raw Output value as raw string representation + --help Show this message and exit. + +.. code-block:: + + $ octoprint config insert_value --help + Usage: octoprint config insert_value [OPTIONS] PATH INDEX VALUE + + Inserts value at index of list behind config key. + + Options: + --json + --help Show this message and exit. + +.. code-block:: + + $ octoprint config remove --help + Usage: octoprint config remove [OPTIONS] PATH + + Removes a config path. + + Options: + --help Show this message and exit. + +.. code-block:: + + $ octoprint config remove_value --help + Usage: octoprint config remove_value [OPTIONS] PATH VALUE + + Removes value from list at config path. + + Options: + --json + --help Show this message and exit. + +.. code-block:: + + $ octoprint config set --help + Usage: octoprint config set [OPTIONS] PATH VALUE + + Sets a config path to the provided value. + + Options: + --bool Interpret value as bool + --float Interpret value as float + --int Interpret value as int + --json Parse value from json + --help Show this message and exit. diff --git a/docs/configuration/config_yaml.rst b/docs/configuration/config_yaml.rst index f469c20866..0cab1bca56 100644 --- a/docs/configuration/config_yaml.rst +++ b/docs/configuration/config_yaml.rst @@ -5,11 +5,12 @@ config.yaml If not specified via the command line, the main configuration file ``config.yaml`` for OctoPrint is expected in its settings folder, which unless defined differently via the command line is located at ``~/.octoprint`` on Linux, at -``%APPDATA%/OctoPrint`` on Windows and at ``~/Library/Application Support/OctoPrint`` on MacOS. If the file is not there, +``%APPDATA%/OctoPrint`` on Windows and at ``~/Library/Application Support/OctoPrint`` on macOS. If the file is not there, you can just create it - it will only get created by OctoPrint once you save settings that deviate from the default settings. Note that many of these settings are available from the "Settings" menu in OctoPrint itself. +They can also be configured via :ref:`config command line interface `. .. contents:: @@ -23,20 +24,17 @@ Use the following settings to enable access control: .. code-block:: yaml accessControl: - # whether to enable access control or not. Defaults to true - enabled: true - # The user manager implementation to use for accessing user information. Currently only a filebased # user manager is implemented which stores configured accounts in a YAML file (Default: users.yaml # in the default configuration folder, see below) - userManager: octoprint.users.FilebasedUserManager + userManager: octoprint.access.users.FilebasedUserManager # The YAML user file to use. If left out defaults to users.yaml in the default configuration folder. - userFile: /path/to/users.yaml + userfile: /path/to/users.yaml # If set to true, will automatically log on clients originating from any of the networks defined in # "localNetworks" as the user defined in "autologinAs". Defaults to false. - autologinLocal: true + autologinLocal: false # The name of the user to automatically log on clients originating from "localNetworks" as. Must # be the name of one of your configured users. @@ -51,6 +49,33 @@ Use the following settings to enable access control: - 127.0.0.0/8 - 192.168.1.0/24 + # Whether to trust Basic Authentication headers. If you have setup Basic Authentication in front of + # OctoPrint and the user names you use there match OctoPrint accounts, by setting this to true users will + # be logged into OctoPrint as the user user during Basic Authentication. Your should ONLY ENABLE THIS if your + # OctoPrint instance is only accessible through a connection locked down through Basic Authentication! + trustBasicAuthentication: false + + # Whether to also check the password provided through Basic Authentication if the Basic Authentication + # header is to be trusted. Disabling this will only match the user name in the Basic Authentication + # header and login the user without further checks. Use with caution. + checkBasicAuthenticationPassword: true + + # Whether to trust remote user headers. If you have setup authentication in front of + # OctoPrint and the user names you use there match OctoPrint accounts, by setting this to true users will + # be logged into OctoPrint as the user provided in the header. Your should ONLY ENABLE THIS if your + # OctoPrint instance is only accessible through a connection locked down through an authenticating reverse proxy! + trustRemoteUser: false + + # Header used by the reverse proxy to convey the authenticated user. + remoteUserHeader: REMOTE_USER + + # If a remote user is not found, add them. Use this only if all users from the remote system can use OctoPrint. + addRemoteUsers: false + + # Secret salt used for password hashing, DO NOT TOUCH. If changed you will no longer be able to log in with your + # existing accounts. + salt: someSecretSalt + .. _sec-configuration-config_yaml-api: API @@ -70,7 +95,8 @@ Settings for the REST API: # Whether to allow cross origin access to the API or not allowCrossOrigin: false - # Additional app api keys, see REST API > Apps in the docs + # Additional app api keys, see REST API > Apps in the docs. + # Deprecated since 1.3.11, to be removed in 1.4.0! apps: "some.app.identifier:some_version": pubkey: @@ -99,6 +125,10 @@ appearance or to modify the order and presence of the various UI components: # acrylic for its frame ;) colorTransparent: false + # Show the internal filename in the files sidebar, if necessary + # UI change only + showInternalFilename: true + # Configures the order and availability of the UI components components: @@ -122,10 +152,14 @@ appearance or to modify the order and presence of the various UI components: navbar: - settings - systemmenu + - plugin_announcements + - plugin_logging + - plugin_pi_support - login # order of sidebar items sidebar: + - plugin_firmware_check - connection - state - files @@ -134,13 +168,13 @@ appearance or to modify the order and presence of the various UI components: tab: - temperature - control - - gcodeviewer + - plugin_gcodeviewer - terminal - timelapse # order of settings, if settings plugins are registered gets extended internally by # section_plugins and all settings plugins - settings + settings: - section_printer - serial - printerprofiles @@ -151,17 +185,40 @@ appearance or to modify the order and presence of the various UI components: - features - webcam - accesscontrol + - plugin_gcodeviewer - api + - plugin_appkeys - section_octoprint - folders - appearance - - logs + - plugin_logging + - plugin_pluginmanager + - plugin_softwareupdate + - plugin_announcements + - plugin_backup + - plugin_tracking + - plugin_errortracking # order of user settings usersettings: - access - interface + # order of wizards + wizard: + - plugin_backup + - plugin_corewizard_acl + + # order of about dialog entries + about: + - about + - plugin_pi_support + - supporters + - authors + - license + - thirdparty + - plugin_pluginmanager + # order of generic templates generic: [] @@ -198,7 +255,7 @@ appearance or to modify the order and presence of the various UI components: tab: - plugin_helloworld - OctoPrint will then turn this into the order ``plugin_helloworld``, ``temperature``, ``control``, ``gcodeviewer``, + OctoPrint will then display the tabs in the order ``plugin_helloworld``, ``temperature``, ``control``, ``plugin_gcodeviewer``, ``terminal``, ``timelapse`` plus any other plugins. @@ -259,178 +316,19 @@ The following settings are only relevant to you if you want to do OctoPrint deve # false, no minification will take place regardless of the minify setting below. bundle: true - # If set to true, OctoPrint will minify its viewmodels (that includes those of plugins). Note: if bundle is + # If set to true, OctoPrint will the core and library javascript assets. Note: if bundle is # set to false, no minification will take place either. minify: true + # If set to true, OctoPrint will also minify the third party plugin javascript assets. Note: if bundle or + # minify are set to false, no minification of the plugin assets will take place either. + minify_plugins: false + # Whether to delete generated web assets on server startup (forcing a regeneration) clean_on_startup: true - # Settings for the virtual printer - virtualPrinter: - - # Whether to enable the virtual printer and include it in the list of available serial connections. - # Defaults to false. - enabled: true - - # Whether to send an additional "ok" after a resend request (like Repetier) - okAfterResend: false - - # Whether to force checksums and line number in the communication (like Repetier), if set to true - # printer will only accept commands that come with linenumber and checksum and throw an error for - # lines that don't. Defaults to false - forceChecksum: false - - # Whether to send "ok" responses with the line number that gets acknowledged by the "ok". Defaults - # to false. - okWithLinenumber: false - - # Number of extruders to simulate on the virtual printer. Map from tool id (0, 1, ...) to temperature - # in °C - numExtruders: 1 - - # Allows pinning certain hotends to a fixed temperature - pinnedExtruders: null - - # Whether to include the current tool temperature in the M105 output as separate T segment or not. - # - # True: > M105 - # < ok T:23.5/0.0 T0:34.3/0.0 T1:23.5/0.0 B:43.2/0.0 - # False: > M105 - # < ok T0:34.3/0.0 T1:23.5/0.0 B:43.2/0.0 - includeCurrentToolInTemps: true - - # Whether to include the selected filename in the M23 File opened response. - # - # True: > M23 filename.gcode - # < File opened: filename.gcode Size: 27 - # False: > M23 filename.gcode - # > File opened - includeFilenameInOpened: true - - # Whether the simulated printer should also simulate a heated bed or not - hasBed: true - - # If enabled, reports the set target temperatures as separate messages from the firmware - # - # True: > M109 S220.0 - # < TargetExtr0:220.0 - # < ok - # > M105 - # < ok T0:34.3 T1:23.5 B:43.2 - # False: > M109 S220.0 - # < ok - # > M105 - # < ok T0:34.3/220.0 T1:23.5/0.0 B:43.2/0.0 - repetierStyleTargetTemperature: false - - # If enabled, uses repetier style resends, sending multiple resends for the same line - # to make sure nothing gets lost on the line - repetierStyleResends: false - - # If enabled, ok will be sent before a commands output, otherwise after or inline (M105) - # - # True: > M20 - # < ok - # < Begin file list - # < End file list - # False: > M20 - # < Begin file list - # < End file list - # < ok - okBeforeCommandOutput: false - - # If enabled, reports the first extruder in M105 responses as T instead of T0 - # - # True: > M105 - # < ok T:34.3/0.0 T1:23.5/0.0 B:43.2/0.0 - # False: > M105 - # < ok T0:34.3/0.0 T1:23.5/0.0 B:43.2/0.0 - smoothieTemperatureReporting: false - - # Whether M20 responses will include filesize or not - # - # True: - # False: - extendedSdFileList: false - - # Forced pause for retrieving from the outgoing buffer - throttle: 0.01 - - # Whether to send "wait" responses every "waitInterval" seconds when serial rx buffer is empty - sendWait: false - - # Interval in which to send "wait" lines when rx buffer is empty - waitInterval: 1 - - # Size of the simulated RX buffer in bytes, when it's full a send from OctoPrint's - # side will block - rxBuffer: 64 - - # Size of simulated command buffer, number of commands. If full, buffered commands will block - # until a slot frees up - commandBuffer: 4 - - # Whether to support the M112 command with simulated kill - supportM112: true - - # Whether to send messages received via M117 back as "echo:" lines - echoOnM117: true - - # Whether to simulate broken M29 behaviour (missing ok after response) - brokenM29: true - - # Whether F is supported as individual command - supportF: false - - # Firmware name to report (useful for testing firmware detection) - firmwareName: Virtual Marlin 1.0 - - # Simulate a shared nozzle - sharedNozzle: false - - # Send "busy" messages if busy processing something - sendBusy: false - - # Simulate a reset on connect - simulateReset: true - - # Lines to send on simulated reset - resetLines: - - start - - Marlin: Virtual Marlin! - - "\x80" - - "SD card ok" - - # Initial set of prepared oks to use instead of regular ok (e.g. to simulate - # mis-sent oks). Can also be filled at runtime via the debug command prepare_ok - preparedOks: [] - - # Format string for ok response. - # - # Placeholders: - # - lastN: last acknowledged line number - # - buffer: empty slots in internal command buffer - # - # Example format string for "extended" ok format: - # ok N{lastN} P{buffer} - okFormatString: ok - - # Format string for M115 output. - # - # Placeholders: - # - firmare_name: The firmware name as defined in firmwareName - m115FormatString: "FIRMWARE_NAME: {firmware_name} PROTOCOL_VERSION:1.0" - - # Whether to include capability report in M115 output - m115ReportCapabilites: false - - # Capabilities to report if capability report is enabled - capabilities: - AUTOREPORT_TEMP: true - - # Simulated ambient temperature in °C - ambientTemperature: 21.3 + # enable or disable the loading animation + showLoadingAnimation: true .. _sec-configuration-config_yaml-estimation: @@ -443,7 +341,7 @@ the estimation of the left print time during an active job utilizes this section .. code-block:: yaml estimation: - # Parameters for the print time estmation during an ongoing print job + # Parameters for the print time estimation during an ongoing print job printTime: # Until which percentage to do a weighted mixture of statistical duration (analysis or # past prints) with the result from the calculated estimate if that's already available. @@ -522,52 +420,16 @@ Use the following settings to enable or disable OctoPrint features: .. code-block:: yaml feature: + # Whether to enable the gcode viewer in the UI or not gCodeVisualizer: true # Whether to enable the temperature graph in the UI or not temperatureGraph: true - # Specifies whether OctoPrint should wait for the start response from the printer before trying to send commands - # during connect. - waitForStartOnConnect: false - - # Specifies whether OctoPrint should send linenumber + checksum with every printer command. Needed for - # successful communication with Repetier firmware - alwaysSendChecksum: false - - # Specifies whether OctoPrint should also send linenumber + checksum with commands that are *not* - # detected as valid GCODE (as in, they do not match the regular expression "^\s*([GM]\d+|T)"). - sendChecksumWithUnknownCommands: false - - # Specifies whether OctoPrint should also use up acknowledgments ("ok") for commands that are *not* - # detected as valid GCODE (as in, they do not match the regular expression "^\s*([GM]\d+|T)"). - unknownCommandsNeedAck: false - - # Whether to ignore the first ok after a resend response. Needed for successful communication with - # Repetier firmware - swallowOkAfterResend: false - # Specifies whether support for SD printing and file management should be enabled sdSupport: true - # Specifies whether firmware expects relative paths for selecting SD files - sdRelativePath: false - - # Whether to always assume that an SD card is present in the printer. - # Needed by some firmwares which don't report the SD card status properly. - sdAlwaysAvailable: false - - # Whether the printer sends repetier style target temperatures in the format - # TargetExtr0: - # instead of attaching that information to the regular M105 responses - repetierTargetTemp: false - - # Whether to enable external heatup detection (to detect heatup triggered e.g. through the printer's LCD panel or - # while printing from SD) or not. Causes issues with Repetier's "first ok then response" approach to - # communication, so disable for printers running Repetier firmware. - externalHeatupDetection: true - # Whether to enable the keyboard control feature in the control tab keyboardControl: true @@ -575,28 +437,20 @@ Use the following settings to enable or disable OctoPrint features: # notifications instead (false) pollWatched: false - # Whether to ignore identical resends from the printer (true, repetier) or not (false) - ignoreIdenticalResends: false - - # If ignoredIdenticalResends is true, how many consecutive identical resends to ignore - identicalResendsCount: 7 - - # Whether to support F on its own as a valid GCODE command (true) or not (false) - supportFAsCommand: false - # Whether to enable model size detection and warning (true) or not (false) modelSizeDetection: true - # Whether to attempt to auto detect the firmware of the printer and adjust settings - # accordingly (true) or not and rely on manual configuration (false) - firmwareDetection: true - # Whether to show a confirmation on print cancelling (true) or not (false) printCancelConfirmation: true - # Whether to block all sending to the printer while a G4 (dwell) command is active (true, repetier) - # or not (false) - blockWhileDwelling: false + # Commands that should never be auto-uppercased when sent to the printer through the Terminal tab. + # Defaults to only M117. + autoUppercaseBlacklist: + - M117 + - M118 + + # whether G90/G91 also influence absolute/relative mode of extruders + g90InfluencesExtruder: false .. _sec-configuration-config_yaml-folder: @@ -653,15 +507,16 @@ Settings pertaining to the server side GCODE analysis implementation. .. code-block:: yaml - # Maximum number of extruders to support/to sanity check for - maxExtruders: 10 + gcodeAnalysis: + # Maximum number of extruders to support/to sanity check for + maxExtruders: 10 - # Pause between each processed GCODE line in normal priority mode, seconds - throttle_normalprio: 0.01 + # Pause between each processed GCODE line in normal priority mode, seconds + throttle_normalprio: 0.01 - # Pause between each processed GCODE line in high priority mode (e.g. on fresh - # uploads), seconds - throttle_highprio: 0.0 + # Pause between each processed GCODE line in high priority mode (e.g. on fresh + # uploads), seconds + throttle_highprio: 0.0 .. _sec-configuration-config_yaml-gcodeviewer: @@ -672,16 +527,17 @@ Settings pertaining to the built in GCODE Viewer. .. code-block:: yaml - # Whether to enable the GCODE viewer in the UI - enabled: true + gcodeViewer: + # Whether to enable the GCODE viewer in the UI + enabled: true - # Maximum size a GCODE file may have on mobile devices to automatically be loaded - # into the viewer, defaults to 2MB - mobileSizeThreshold: 2097152 + # Maximum size a GCODE file may have on mobile devices to automatically be loaded + # into the viewer, defaults to 2MB + mobileSizeThreshold: 2097152 - # Maximum size a GCODE file may have to automatically be loaded into the viewer, - # defaults to 20MB - sizeThreshold: 20971520 + # Maximum size a GCODE file may have to automatically be loaded into the viewer, + # defaults to 20MB + sizeThreshold: 20971520 .. _sec-configuration-config_yaml-plugins: @@ -700,6 +556,11 @@ plugins are tracked: _disabled: - ... + # Identifiers of plugins for which python compatibility information will be ignored and + # the plugin considered compatible in any case. Only for development, do NOT use in production. + _forcedCompatible: + - ... + # The rest are individual plugin settings, each tracked by their identifier, e.g.: some_plugin: some_setting: true @@ -800,6 +661,10 @@ Use the following settings to configure the serial connection to the printer: # Defaults to 30 sec communication: 30 + # Timeout during serial communication when busy protocol support is detected, in seconds. + # Defaults to 3 sec + communicationBusy: 3 + # Timeout after which to query temperature when no target is set temperature: 5 @@ -824,7 +689,7 @@ Use the following settings to configure the serial connection to the printer: # Maximum number of write attempts to serial during which nothing can be written before the communication # with the printer is considered dead and OctoPrint will disconnect with an error - maxWritePasses: + maxWritePasses: 5 # Use this to define additional patterns to consider for serial port listing. Must be a valid # "glob" pattern (see http://docs.python.org/2/library/glob.html). Defaults to not set. @@ -836,6 +701,24 @@ Use the following settings to configure the serial connection to the printer: additionalBaudrates: - 123456 + # Commands which should not be sent to the printer, e.g. because they are known to block serial + # communication until physical interaction with the printer as is the case on most firmwares with + # the default M0 and M1. + blockedCommands: + - M0 + - M1 + + # Commands which should not be sent to the printer and just silently ignored. + # An example of when you may wish to use this could be useful if you wish to manually change a filament on M600, + # by using that as a Pausing command (below) + ignoredCommands: + + # Commands which should cause OctoPrint to pause any ongoing prints. + pausingCommands: + - M0 + - M1 + - M25 + # Commands which are known to take a long time to be acknowledged by the firmware. E.g. # homing, dwelling, auto leveling etc. Defaults to the below commands. longRunningCommands: @@ -858,10 +741,6 @@ Use the following settings to configure the serial connection to the printer: helloCommand: - M110 N0 - # Commands that should never be auto-uppercased when sent to the printer. Defaults to only M117. - autoUppercaseBlacklist: - - M117 - # Whether to disconnect on errors or not disconnectOnErrors: true @@ -872,13 +751,78 @@ Use the following settings to configure the serial connection to the printer: # impact, leave on if possible please logResends: true + # Specifies whether OctoPrint should wait for the start response from the printer before trying to send commands + # during connect. + waitForStartOnConnect: false + + # Specifies whether OctoPrint should send linenumber + checksum with every printer command. Needed for + # successful communication with Repetier firmware + alwaysSendChecksum: false + + # Specifies whether OctoPrint should also send linenumber + checksum with commands that are *not* + # detected as valid GCODE (as in, they do not match the regular expression "^\s*([GM]\d+|T)"). + sendChecksumWithUnknownCommands: false + + # Specifies whether OctoPrint should also use up acknowledgments ("ok") for commands that are *not* + # detected as valid GCODE (as in, they do not match the regular expression "^\s*([GM]\d+|T)"). + unknownCommandsNeedAck: false + + # Whether to ignore the first ok after a resend response. Needed for successful communication with + # Repetier firmware + swallowOkAfterResend: false + + # Specifies whether firmware expects relative paths for selecting SD files + sdRelativePath: false + + # Whether to always assume that an SD card is present in the printer. + # Needed by some firmwares which don't report the SD card status properly. + sdAlwaysAvailable: false + + # Whether the printer sends repetier style target temperatures in the format + # TargetExtr0: + # instead of attaching that information to the regular M105 responses + repetierTargetTemp: false + + # Whether to enable external heatup detection (to detect heatup triggered e.g. through the printer's LCD panel or + # while printing from SD) or not. Causes issues with Repetier's "first ok then response" approach to + # communication, so disable for printers running Repetier firmware. + externalHeatupDetection: true + + # Whether to ignore identical resends from the printer (true, repetier) or not (false) + ignoreIdenticalResends: false + + # If ignoredIdenticalResends is true, how many consecutive identical resends to ignore + identicalResendsCount: 7 + + # Whether to support F on its own as a valid GCODE command (true) or not (false) + supportFAsCommand: false + + # Whether to attempt to auto detect the firmware of the printer and adjust settings + # accordingly (true) or not and rely on manual configuration (false) + firmwareDetection: true + + # Whether to block all sending to the printer while a G4 (dwell) command is active (true, repetier) + # or not (false) + blockWhileDwelling: false + # Whether to support resends without follow-up ok or not supportResendsWithoutOk: false # Whether to "manually" trigger an ok for M29 (a lot of versions of this command are buggy and - # the responds skips on the ok) + # the response skips on the ok) triggerOkForM29: true + # Percentage of resend requests among all sent lines that should be considered critical + resendRatioThreshold: 10 + + capabilities: + + # Whether to enable temperature autoreport in the firmware if its support is detected + autoreport_temp: true + + # Whether to shorten the communication timeout if the firmware seems to support the busy protocol + busy_protocol: true + .. _sec-configuration-config_yaml-server: Server @@ -904,12 +848,19 @@ Use the following settings to configure the server: # reset the setting to false startOnceInSafeMode: false + # Signals to OctoPrint that the last startup was incomplete. OctoPrint will then startup + # in safe mode + incompleteStartup: false + + # Set this to true to make OctoPrint ignore incomplete startups. Helpful for development. + ignoreIncompleteStartup: false + # Secret key for encrypting cookies and such, randomly generated on first run secretKey: someSecretKey # Settings if OctoPrint is running behind a reverse proxy (haproxy, nginx, apache, ...). # These are necessary in order to make OctoPrint generate correct external URLs so - # that AJAX requests and download URLs work. + # that AJAX requests and download URLs work, and so that client IPs are read correctly. reverseProxy: # The request header from which to determine the URL prefix under which OctoPrint @@ -943,6 +894,33 @@ Use the following settings to configure the server: # (X-Forwarded-Host by default, see above) with forwarded requests. hostFallback: + # List of trusted downstream servers for which to ignore the IP address when trying to determine + # the connecting client's IP address. If you have OctoPrint behind more than one reverse proxy + # you should add their IPs here so that they won't be interpreted as the client's IP. One reverse + # proxy will be handled correctly by default. + trustedDownstream: + - 192.168.1.254 + - 192.168.23.42 + + # Whether to allow OctoPrint to be embedded in a frame or not. Note that depending on your setup you might + # have to set SameSite to None, Secure to true and serve OctoPrint through a reverse proxy that enables https + # for cookies and thus logging in to work + allowFraming: true + + # Settings for further configuration of the cookies that OctoPrint sets (login, remember me, ...) + cookies: + # SameSite setting to use on the cookies. Possible values are None, Lax and Strict. Defaults to not set but + # be advised that many browsers now default to Lax unless set as Secure, explicitly setting the cookie type + # here and served over https, which causes issues with embedding OctoPrint in frames. + # + # See also https://www.chromestatus.com/feature/5088147346030592, + # https://www.chromestatus.com/feature/5633521622188032 and issue #3482 + samesite: lax + + # Whether to set the Secure flag to true on cookies. Defaults to false. Only set to true if you are running + # OctoPrint behind a reverse proxy taking care of SSL termination. + secure: false + # Settings for file uploads to OctoPrint, such as maximum allowed file size and # header suffixes to use for streaming uploads. OctoPrint does some nifty things internally in # order to allow streaming of large file uploads to the application rather than just storing @@ -976,6 +954,12 @@ Use the following settings to configure the server: # Command to shut down the system OctoPrint is running on, defaults to being unset systemShutdownCommand: sudo shutdown -h now + # pip command associated with OctoPrint, used for installing plugins and updates, + # if unset (default) the command will be autodetected based on the current python + # executable - unless you have a really special setup this is the right way to do + # it and there should be no need to ever even touch this setting + localPipCommand: None + # Configuration of the regular online connectivity check onlineCheck: # whether the online check is enabled, defaults to false due to valid privacy concerns @@ -1022,6 +1006,15 @@ Use the following settings to configure the server: # How many days to leave unused entries in the preemptive cache config until: 7 + # Configuration of the client IP check to warn about connections from external networks + ipCheck: + + # whether to enable the check, defaults to true + enabled: true + + # additional non-local subnets to consider trusted, in CIDR notation, e.g. "192.168.1.0/24" + trustedSubnets: [] + .. note:: @@ -1035,9 +1028,24 @@ Use the following settings to configure the server: * ``X-Scheme``: should contain your custom URL scheme to use (if different from ``http``), e.g. ``https`` If you use these headers OctoPrint will work both via the reverse proxy as well as when called directly. Take a look - `into OctoPrint's wiki `_ for some + `into OctoPrint's wiki `_ for some examples on how to configure this. +.. note:: + + If you want to embed OctoPrint in a frame, you'll need to set ``allowFraming`` to ``true`` or your browser will + prevent this. + + In future browser builds you will also have to make sure you frame is on the same domain as OctoPrint or that + OctoPrint is served via https through a reverse proxy and has set ``cookies.secure`` to ``true`` or your browser + will refuse to persist cookies and logging in will not work. + + See also `Cookies default to SameSite=Lax `_ and + `Reject insecure SameSite=None cookies `_ as well as + `this ticket `_, + and `this twitter thread `_ on why OctoPrint cannot + solve this on its own/ship with https that doesn't cause scary warnings in your browser. + .. _sec-configuration-config_yaml-slicing: Slicing @@ -1051,14 +1059,14 @@ Settings for the built-in slicing support: slicing: # Whether to enable slicing support or not - enabled: + enabled: true # Default slicer to use - defaultSlicer: cura + defaultSlicer: null # Default slicing profiles per slicer defaultProfiles: - cura: ... + curalegacy: ... .. _sec-configuration-config_yaml-system: @@ -1067,7 +1075,12 @@ System Use the following settings to add custom system commands to the "System" dropdown within OctoPrint's top bar. -Commands consist of a name, an action identifier, the commandline to execute and an optional confirmation message to +Commands consist of a ``name`` shown to the user, an ``action`` identifier used by the code and the actual +``command`` including any argument needed for its execution. +By default OctoPrint blocks until the command has returned so that the exit code can be used to show a success +or failure message; use the flag ``async: true`` for commands that don't return. + +Optionally you can add a confirmation message to display before actually executing the command (should be set to False if a confirmation dialog is not desired). The following example defines a command for shutting down the system under Linux. It assumes that the user under which @@ -1082,7 +1095,7 @@ OctoPrint is running is allowed to do this without password entry: command: sudo shutdown -h now confirm: You are about to shutdown the system. -You can also add an divider by setting action to divider like this: +You can also add a divider by setting action to divider like this: .. code-block:: yaml @@ -1124,11 +1137,13 @@ Use `Javascript regular expressions ``: the plugin ````, e.g. ``octoprint.plugins.discovery`` to change the log level of - the `Discovery plugin `_ or ``octoprint.plugins.cura`` - to change the log level of the `Cura plugin `_. + the :ref:`Discovery plugin ` or ``octoprint.plugins.backup`` + to change the log level of the :ref:`Backup plugin `. * ``octoprint.slicing``: the slicing sub system This list will be expanded when deemed necessary. @@ -81,8 +81,8 @@ used for the ``serial.log`` and the ``console`` handler used for the output to s maxBytes: 2097152 # 2 * 1024 * 1024 = 2 MB in bytes filename: /path/to/octoprints/logs/serial.log -You can find more information on the used logging handlers in the -`Python documentation on logging handlers `_. +You can find more information on the used logging handlers in the Python documentation on +:py:mod:`logging.handlers`. Changing logging formatters --------------------------- @@ -97,4 +97,4 @@ expressed in YAML as follows: format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" The possible keys for the logging format can be found in the -`Python documentation on LogRecord attributes `_. +:ref:`Python documentation on LogRecord attributes `. diff --git a/docs/development/branches.rst b/docs/development/branches.rst new file mode 100644 index 0000000000..57ec181586 --- /dev/null +++ b/docs/development/branches.rst @@ -0,0 +1,75 @@ +.. _sec-development-branches: + +OctoPrint's branching model +=========================== + +There are three main branches in OctoPrint: + +``master`` + The master branch always contains the current stable release plus any changes + since made to *documentation*, *CI related tests* or *Github meta files*. OctoPrint's actual + code will only be modified on new releases. Will have a version number following + the scheme ``..`` (e.g. ``1.5.1``). +``maintenance`` + Improvements and fixes of the current release that make up + the next release go here. More or less continuously updated. You can consider + this a preview of the next release version. It should be very stable at all + times. Anything you spot in here helps tremendously with getting a rock solid + next stable release, so if you want to help out development, running the + ``maintenance`` branch and reporting back anything you find is a very good way + to do that. Will usually have a version number following the scheme + ``..<0>.dev`` for an OctoPrint version of ``..`` + (e.g. ``1.5.0.dev114``). +``devel`` + Ongoing development of what will go into the next big + release (MAJOR version number increases) will happen on this branch. Usually + kept stable, sometimes stuff can break though. Backwards incompatible changes will + be encountered here. Can be considered the "bleeding edge". Will usually have a version + number following the scheme ``.<0>.0.dev`` for a current + OctoPrint version of ``..`` (e.g. ``2.0.0.dev123``). + +There are couple more RC and staging branches that see regular use: + +``staging/bugfix`` + Any preparation for potential bugfix releases takes place here. + Version number follows the scheme ``..`` (e.g. ``1.5.1``) for a current release + of ``..``. +``rc/maintenance`` + This branch is reserved for future releases that have graduated from + the ``maintenance`` branch and are now being pushed on the "Maintenance" + pre release channel for further testing. Version number follows the scheme + ``..rc`` (e.g. ``1.5.0rc1``). +``staging/maintenance`` + Any preparation for potential follow-up RCs takes place here. + Version number follows the scheme ``..rc.dev`` (e.g. + ``1.5.0rc2.dev3``) for a current Maintenance RC of ``..``. +``rc/devel`` + This branch is reserved for future releases that have graduated from + the ``devel`` branch and are now being pushed on the "Devel" pre release channel + for further testing. Version number follows the scheme ``.0.0rc`` (e.g. ``2.0.0rc1``) + for a current stable OctoPrint version of ``..``. +``staging/devel`` + Any preparation for potential follow-up Devel RCs takes place + here. Version number follows the scheme ``.0.0rc.dev`` (e.g. + ``2.0.0rc2.dev12``) for a current Devel RC of ``.0.0rc``. + +Additionally, from time to time you might see other branches pop up in the repository. +Those usually have one of the following prefixes: + +``bug/...`` + Fixes under development that are to be merged into the ``staging/bugfix`` + and later the ``master`` branch. +``fix/...`` + Fixes under development that are to be merged into the ``maintenance`` + and ``devel`` branches. +``improve/...`` + Improvements under development that are to be merged into the + ``maintenance`` and ``devel`` branches. +``dev/...`` or ``feature/...`` + New functionality under development that is to be merged + into the ``devel`` branch. + +There are also a few older development branches that are slowly being migrated or deleted. + +All these branches and branch patterns are set up to automatically get a correct :ref:`version number ` +generated through versioneer and thus should also be adhered to during development. diff --git a/docs/development/environment.rst b/docs/development/environment.rst index 8308712b71..b8d989ae3b 100644 --- a/docs/development/environment.rst +++ b/docs/development/environment.rst @@ -1,101 +1,176 @@ .. _sec-development-environment: +************************************ Setting up a Development environment -==================================== +************************************ .. _sec-development-environment-source: Obtaining, building and running the source ------------------------------------------- +========================================== -This describes the general steps in obtaining, building and running. OS specific instructions can be found +This describes the **general, platform agnostic** steps in obtaining, building and running. OS specific instructions can be found below. * Prerequisites: - * `Python 2.7 `_ including ``pip``, ``setuptools`` and ``virtualenv`` + * `Python 3.7 `_ including ``pip``, ``setuptools`` and ``virtualenv`` * `Git `_ - * Checkout the OctoPrint sources from their Git repository: ``git clone https://github.com/foosel/OctoPrint.git`` + * Checkout the OctoPrint sources from their Git repository: + + * ``git clone https://github.com/OctoPrint/OctoPrint.git`` + * Enter the checked out source folder: ``cd OctoPrint`` - * Create a virtual environment in the checked out source folder to use for installing and running OctoPrint and its - dependencies (this avoids potential versioning issues for the dependencies with system wide installed - instances): ``virtualenv venv`` - * Activate the virtual environment: ``source venv/bin/activate`` (Linux, MacOS) or - ``source venv/Scripts/activate`` (Git Bash under Windows, see below) - * Update ``pip`` in the virtual environment: ``pip install --upgrade pip`` + * Create a virtual environment in the checked out source folder to use for + installing and running OctoPrint and its dependencies. Creating virtual environments avoids potential versioning + issues for the dependencies with system wide installed instances: ``virtualenv --python=python3 venv3`` + + .. note:: + + This assumes that the ``python3`` binary is available directly on your ``PATH``. If + it cannot be found on your ``PATH`` like this you'll need to specify the full path here, + e.g. ``virtualenv --python=/path/to/python3/bin/python venv3`` + + * Activate the virtual environment: ``source venv3/bin/activate`` (Linux, macOS) or ``source venv3/Scripts/activate`` (Git Bash under Windows, see below) + + * Update ``pip`` in the virtual environment: + + * ``pip install --upgrade pip`` + * Install OctoPrint in `"editable" mode `_, - including its regular *and* development and plugin development dependencies: ``pip install -e .[develop,plugins]`` + including its regular *and* development and plugin development dependencies: + + * ``pip install -e '.[develop,plugins,docs]'`` + + * Set up the pre-commit hooks that make sure any changes you do adhere to the styling rules: + + * ``pre-commit install`` + + * Tell ``git`` where to find the file with revisions to exclude for ``git blame``: + + * ``git config blame.ignoreRevsFile .git-blame-ignore-revs`` When the virtual environment is activated you can then: * run the OctoPrint server via ``octoprint serve`` - * run the test suite from the checked out source folder via ``nosetests --with-doctest`` - * build the documentation from the ``docs`` sub folder of the checked out sources via ``sphinx-build -b html . _build`` + * run the test suite from the checked out source folder via ``pytest`` + * trigger the pre-commit check suite manually from the checked out source folder via + ``pre-commit run --hook-stage manual --all-files`` + * build the documentation running ``sphinx-build -b html . _build`` in the ``docs`` + folder -- the documentation will be available in the newly created ``_build`` + directory. You can simply browse it locally by opening ``index.html`` + +If a Python 2 environment is also desired (e.g. for maintenance reasons), also do the following extra steps: + + * Prerequisites: + + * `Python 2.7 `_ including ``pip``, ``setuptools`` and ``virtualenv`` + + * Create a virtual environment in the checked out source folder for Python 2: ``virtualenv --python=python2 venv2`` + + .. note:: + + This assumes that the ``python2`` binary is available directly on your ``PATH``. If + it cannot be found on your ``PATH`` like this you'll need to specify the full path here, + e.g. ``virtualenv --python=/path/to/python2/bin/python venv2`` + + * Activate the virtual environment: ``source venv2/bin/activate`` (Linux, macOS) or ``source venv2/Scripts/activate`` (Git Bash under Windows, see below) + + * Update ``pip`` in the virtual environment: + + * ``pip install --upgrade pip`` + + * Install OctoPrint in `"editable" mode `_, + including its regular *and* development and plugin development dependencies: + + * ``pip install -e '.[develop,plugins]'`` .. _sec-development-environment-source-linux: Linux -..... +----- This assumes you'll host your OctoPrint development checkout at ``~/devel/OctoPrint``. If you want to use a different location, please substitute accordingly. -First make sure you have python including its header files, pip, setuptools, virtualenv, git and some build requirements +First make sure you have python 2 and 3 including their header files, pip, setuptools, virtualenv, git and some build requirements installed: * On apt based distributions (e.g. Debian, Ubuntu, ...): .. code-block:: none - sudo apt-get install python python-pip python-dev python-setuptools python-virtualenv git libyaml-dev build-essential + sudo apt-get install python python-pip python-dev python-setuptools python-virtualenv python3 python3-virtualenv python3-dev git libyaml-dev build-essential - * On zypper based distributions (example below for SLES 12 SP2): +Then: - .. code-block:: none +.. code-block:: none - sudo zypper ar https://download.opensuse.org/repositories/devel:/languages:/python/SLE_12_SP2/ python_devel - sudo zypper ref - sudo zypper in python python-pip python-devel python-setuptools python-virtualenv git libyaml-devel - sudo zypper in -t pattern Basis-Devel + cd ~/devel + git clone https://github.com/OctoPrint/OctoPrint.git + cd OctoPrint + virtualenv --python=python3 venv3 + source ./venv3/bin/activate + pip install --upgrade pip + pip install -e '.[develop,plugins,docs]' + pre-commit install + git config blame.ignoreRevsFile .git-blame-ignore-revs -.. todo:: +.. _sec-development-environment-linux-python2: - Using a Linux distribution that doesn't use ``apt`` or ``zypper``? Please send a - `Pull Request `_ to get the necessary - steps into this guide! +Optional Python 2 environment +............................. + +If a Python 2 environment is also desired: + + * On apt based distributions (e.g. Debian, Ubuntu, ...): + + .. code-block:: none + + sudo apt-get install python2 python2-dev python-pip python-setuptools python-virtualenv Then: .. code-block:: none - cd ~/devel - git clone https://github.com/foosel/OctoPrint.git - cd OctoPrint - virtualenv venv - source ./venv/bin/activate + virtualenv --python=python2 venv2 + source ./venv2/bin/activate pip install --upgrade pip - pip install -e .[develop,plugins] + pip install -e '.[develop,plugins]' + +You can then start OctoPrint via ``octoprint`` after activating one of the two virtual environments. + +.. todo:: -You can then start OctoPrint via ``~/devel/OctoPrint/venv/bin/octoprint`` or just ``octoprint`` if you activated the virtual -environment. + Using a Linux distribution that doesn't use ``apt``? Please send a + `Pull Request `_ to get the necessary + steps into this guide! .. _sec-development-environment-windows: Windows -....... +------- This assumes you'll host your OctoPrint development checkout at ``C:\Devel\OctoPrint``. If you want to use a different location, please substitute accordingly. First download & install: - * `Python 2.7.12 Windows x86 MSI installer `_ + * `Git for Windows `_ + + * `Latest *stable* Python 3 release from python.org `_ * make sure to have the installer add Python to the ``PATH`` and have it install ``pip`` too + * it's recommended to install Python 3 into ``C:\Python3`` - if you select + different install locations please substitute accordingly + * it's also recommended to install for all users + + * `Build Tools For Visual Studio 2019 `_ + + * install "C++ build tools" and ensure the latest versions of "MSVCv142 - VS 2019 C++ x64/x86 build tools" and + "Windows 10 SDK" are checked under individual components. - * `Microsoft Visual C++ Compiler for Python 2.7 `_ - * `Git for Windows `_ Open the Git Bash you just installed and in that: @@ -103,23 +178,82 @@ Open the Git Bash you just installed and in that: pip install virtualenv cd /c/Devel - git clone https://github.com/foosel/OctoPrint.git + git clone https://github.com/OctoPrint/OctoPrint.git cd OctoPrint - virtualenv venv - source ./venv/Scripts/activate + virtualenv --python=C:/Python3/python.exe venv3 + source ./venv3/Scripts/activate pip install --upgrade pip - pip install -e .[develop,plugins] + python -m pip install -e '.[develop,plugins,docs]' + pre-commit install + git config blame.ignoreRevsFile .git-blame-ignore-revs + +.. _sec-development-environment-windows-python2: + +Optional Python 2 environment +............................. + +If a Python 2 environment is also desired, then also download and install + + * `Python 2.7.18 MSI installer `_ (mirrored on octoprint.org) + + * it's recommended to install Python 2.7 into ``C:\Python27`` - if you select + different install locations please substitute accordingly + * it's also recommended to install for all users + + * `Microsoft Visual C++ Compiler for Python 2.7 `_ (mirrored on octoprint.org) + +Then: + +.. code-block:: none + + cd /c/Devel/OctoPrint + virtualenv --python=C:/Python27/python.exe venv2 + source ./venv2/Scripts/activate + python -m pip install --upgrade pip + pip install -e '.[develop,plugins]' + +.. _sec-development-environment-windows-optional: + +Optional but recommended tools +.............................. + +These are some tools that are recommended but not required to have on hand: + + * `Visual Studio Code `_ + + * `Windows Terminal `_ + + Add the following profile to ``profiles.list`` in the settings, that will allow you to + easily start Git Bash from the terminal: + + .. code-block:: js + + { + "guid": "{3df4550c-eebd-496c-a189-e55f2f8b01ce}", + "hidden": false, + "name": "Git Bash", + "commandline": "C:\\Program Files\\Git\\bin\\bash.exe --login -i", + "startingDirectory": "C:\\Devel", + "tabTitle": "Git Bash", + "suppressApplicationTitle": true + }, .. _sec-development-environment-mac: Mac OS X -........ +-------- .. note:: - This guide is based on the `Setup Guide for Mac OS X on OctoPrint's wiki `_. + This guide is based on the `Setup Guide for Mac OS X on OctoPrint's Community Forum `_. Please report back if it works for you, due to lack of access to a Mac I cannot test it myself. Thanks. +.. todo:: + + This guide is not yet adapted to the concurrent use of Python 2 and 3 environments during development. Please send a + `Pull Request `_ to get the necessary + steps into this guide! + This assumes you'll host your OctoPrint development checkout at ``~/devel/OctoPrint``. If you want to use a different location, please substitute accordingly. @@ -137,6 +271,10 @@ You'll need a user account with administrator privileges. * ``ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"`` * ``brew install python`` + * Install `pip `_ + + * ``python -m ensurepip --upgrade`` + * Install `virtualenv `_ * ``pip install virtualenv`` @@ -146,73 +284,209 @@ You'll need a user account with administrator privileges. .. code-block:: none cd ~/devel - git clone https://github.com/foosel/OctoPrint.git + git clone https://github.com/OctoPrint/OctoPrint.git cd OctoPrint virtualenv venv source venv/bin/activate pip install --upgrade pip - pip install -e .[develop,plugins] + pip install -e '.[develop,plugins]' + pre-commit install + git config blame.ignoreRevsFile .git-blame-ignore-revs .. _sec-development-environment-ides: IDE Setup ---------- +========= .. todo:: Using another IDE than the ones below? Please send a - `Pull Request `_ to get the necessary + `Pull Request `_ to get the necessary steps into this guide! .. _sec-development-environment-ides-pycharm: PyCharm -....... +------- - "File" > "Open ...", select OctoPrint checkout folder (e.g. ``~/devel/OctoPrint`` or ``C:\Devel\OctoPrint``) - - "File" > "Settings ..." > "Project: OctoPrint" > "Project Interpreter" > "Add local ...", select OctoPrint venv - folder (e.g. ``~/devel/OctoPrint/venv`` or ``C:\Devel\OctoPrint\venv``) + - Register virtual environments: + + - **(Linux, Windows)** "File" > "Settings ..." > "Project: OctoPrint" > "Project Interpreter" > "Add local ...", + select OctoPrint ``venv3`` folder (e.g. ``~/devel/OctoPrint/venv3`` or ``C:\Devel\OctoPrint\venv3``). + - **(macOS)** "PyCharm" > "Preferences ..." > "Project: OctoPrint" > "Project Interpreter" > "Add ..." > + "Virtualenv Environment > "Existing Environment", select OctoPrint ``venv3`` folder (e.g. ``~/devel/OctoPrint/venv3``). + + If desired, repeat for the ``venv2`` folder, or any other additional Python venvs. + - Right click "src" in project tree, mark as source folder - Add Run/Debug Configuration, select "Python": * Name: OctoPrint server - * Script: path to ``run`` in the OctoPrint checkout folder (e.g. ``~/devel/OctoPrint/run`` or ``C:\Devel\OctoPrint\run``) - * Script parameters: ``serve --debug`` + * Module name: ``octoprint`` + * Parameters: ``serve --debug`` * Project: ``OctoPrint`` - * Python interpreter: the ``venv`` local virtual environment + * Python interpreter: Project Default * Working directory: the OctoPrint checkout folder (e.g. ``~/devel/OctoPrint`` or ``C:\Devel\OctoPrint``) - * If you want dependencies to auto-update on run if necessary: "Before Launch" > "+" > "Run external tool" > "+" + * If you want build artifacts to be cleaned up on run (recommended): "Before Launch" > "+" > "Run external tool" > "+" + + * Name: Clean build directory + * Program: ``$ModuleSdkPath$`` + * Parameters: ``setup.py clean`` + * Working directory: ``$ProjectFileDir$`` + + * If you want dependencies to auto-update on run if necessary (recommended): "Before Launch" > "+" > "Run external tool" > "+" * Name: Update OctoPrint dependencies - * Program: ``$PyInterpreterDirectory$/pip`` (or ``$PyInterpreterDirectory$/pip.exe`` on Windows) - * Parameters: ``install -e .[develop,plugins]`` + * Program: ``$ModuleSdkPath$`` + * Parameters: ``-m pip install -e '.[develop,plugins]'`` * Working directory: ``$ProjectFileDir$`` - - Add Run/Debug Configuration, select "Python tests" and therein "Nosetests": + Note that sadly that seems to cause some hiccups on current PyCharm versions due to ``$PyInterpreterDirectory$`` + being empty sometimes, so if this fails to run on your installation, you should update your dependencies manually + for now. - * Name: OctoPrint nosetests - * Target: Path, ``.`` + - Add Run/Debug Configuration, select "Python tests" and therein "pytest": + + * Name: OctoPrint tests + * Target: Custom * Project: ``OctoPrint`` - * Python interpreter: the ``venv`` local virtual environment + * Python interpreter: Project Default * Working directory: the OctoPrint checkout folder (e.g. ``~/devel/OctoPrint`` or ``C:\Devel\OctoPrint``) * Just like with the run configuration for the server you can also have the dependencies auto-update on run of the tests, see above on how to set this up. - - Add Run/Debug Configuration, select "Python docs" and therein "Sphinx task" + - Add Run/Debug Configuration, select "Python": * Name: OctoPrint docs - * Command: ``html`` - * Input: the ``docs`` folder in the OctoPrint checkout folder (e.g. ``~/devel/OctoPrint/docs`` or - ``C:\Devel\OctoPrint\docs``) - * Output: the ``docs/_build`` folder in the OctoPrint checkout folder (e.g. ``~/devel/OctoPrint/docs/_build`` or - ``C:\Devel\OctoPrint\docs\_build``) + * Module name: ``sphinx.cmd.build`` + * Parameters: ``-v -T -E ./docs ./docs/_build -b html`` * Project: ``OctoPrint`` - * Python interpreter: the ``venv`` local virtual environment + * Python interpreter: ``venv3`` environment (the docs build requires Python 3) + * Working directory: the OctoPrint checkout folder (e.g. ``~/devel/OctoPrint`` or ``C:\Devel\OctoPrint``) * Just like with the run configuration for the server you can also have the dependencies auto-update when building the documentation, see above on how to set this up. + Note that this requires you to also have installed the additional ``docs`` dependencies into the Python 3 venv as + described above via ``pip install -e '.[develop,plugins,docs]'``. + + - Settings > Tools > File Watchers (you might have to enable this, it's a bundled plugin), add new: + + * Name: pre-commit + * File type: Python + * Scope: Module 'OctoPrint' + * Program: ``/bin/pre-commit`` (Linux) or ``/Scripts/pre-commit`` (Windows) + * Arguments: ``run --hook-stage manual --files $FilePath$`` + * Output paths to refresh: ``$FilePath$`` + * Working directory: ``$ProjectFileDir$`` + * disable "Auto-save edited files to trigger the watched" + * enable "Trigger the watched on external changes" + +To switch between virtual environments (e.g. Python 3 and 2), all you need to do now is change the Project Default Interpreter and restart +OctoPrint. On current PyCharm versions you can do that right from a small selection field in the footer of the IDE. +Otherwise go through Settings. + .. note:: Make sure you are running a PyCharm version of 2016.1 or later, or manually fix `a debugger bug contained in earlier versions `_ or plugin management will not work in your developer install when running OctoPrint from PyCharm in debug mode. + +Visual Studio Code (vscode) +--------------------------- + + - Install Visual Studio Code from `code.visualstudio.com `_ + - Open folder select OctoPrint checkout folder (e.g. ``~/devel/OctoPrint`` or ``C:\Devel\OctoPrint``) + + - Create a directory ``.vscode`` if not already present in the root of the project + + - Create the following files inside the ``.vscode`` directory + + settings.json + .. code-block:: json + + { + "python.defaultInterpreterPath": "venv3/bin/python", + "python.formatting.provider": "black", + "python.formatting.blackArgs": [ + "--config", + "black.toml" + ], + "editor.formatOnSave": true, + "python.sortImports.args": [ + "--profile=black", + ], + "[python]": { + "editor.codeActionsOnSave": { + "source.organizeImports": true + } + }, + "python.linting.pylintEnabled": false, + "python.linting.flake8Enabled": true, + "python.linting.enabled": true + } + + tasks.json + .. code-block:: json + + { + "version": "2.0.0", + "tasks": [ + { + "label": "clean build artifacts", + "type": "shell", + "command": "python ./setup.py clean" + }, + { + "label": "build docs", + "type": "shell", + "command": "sphinx-build -b html ./docs ./docs/_build" + } + ] + } + + + launch.json + .. code-block:: json + + { + "version": "0.2.0", + "configurations": [ + { + "name": "OctoPrint", + "type": "python", + "request": "launch", + "module": "octoprint", + "args": [ + "serve", + "--debug" + ], + "cwd": "${workspaceFolder}/src", + "preLaunchTask": "clean build artifacts" + } + ] + } + + In the terminal install the python extension by running this command: + + .. code-block:: bash + + code --install-extension ms-python.python + + In vscode terminal, or with venv active install code formatter black and linter flake8 by running: + + .. code-block:: bash + + python -m pip install -U black flake8 flake8-bugbear + + Summary of vscode config: + + * Pressing ``F5`` will now start OctoPrint in debug mode + + * Your terminal inside vscode uses the virtual python environment + + * Saving a file will run an auto formatter and import sort + + * ``Ctrl+Shift+B`` can be used to run the ``build docs`` task to rebuild the documentation + + diff --git a/docs/development/index.rst b/docs/development/index.rst index 8b30385920..249434d25f 100644 --- a/docs/development/index.rst +++ b/docs/development/index.rst @@ -7,5 +7,11 @@ Development .. toctree:: :maxdepth: 3 + versioning.rst + branches.rst environment.rst virtual_printer.rst + request-profiling.rst + +If you are interested in contributing code to OctoPrint, first of all: Thank you! Please make sure to read the +`Contribution Guidelines `_! diff --git a/docs/development/request-profiling.rst b/docs/development/request-profiling.rst new file mode 100644 index 0000000000..a60c82a682 --- /dev/null +++ b/docs/development/request-profiling.rst @@ -0,0 +1,20 @@ +.. _sec-request-profiling: + +Profiling requests +================== + +Once you have a development environment set up, you will need to launch +OctoPrint using ``serve --debug`` as parameters. + +At this point, you are able to make the exact same requests as before. To +profile a specific request, just add ``?perfprofile`` or ``&perfprofile`` to the +request parameters. The request will be rendered as usual, but you will receive +an html document with the profiling results instead of the contents of the +response. + +Errors +------ + +If you receive a ``500: Internal Server Error`` and a ``ModuleNotFoundError: No +module named 'pyinstrument'`` in the console, you didn't install development +dependencies. Do that now using ``pip install -e '.[develop]'``. diff --git a/docs/development/versioning.rst b/docs/development/versioning.rst new file mode 100644 index 0000000000..2d9dab4c48 --- /dev/null +++ b/docs/development/versioning.rst @@ -0,0 +1,31 @@ +.. _sec-development-versions: + +OctoPrint's versioning strategy +=============================== + +OctoPrint's version numbers follow `PEP440 `_, with a version format of +**MAJOR.MINOR.PATCH** following the contract of `semantic versioning `_. + +The **PATCH** version number will increase in case of hotfix releases [#f1]_. +Releases that only change the patch number indicate that they only contain bug fixes, and usually +only hotfixes at that. Example: ``1.5.0`` to ``1.5.1``. + +The **MINOR** version number increases with releases that add new functionality while maintaining +backwards compatibility on the documented APIs (both internal and external). Example: ``1.4.x`` to ``1.5.0``. + +Finally, the **MAJOR** version number increases if there are breaking API changes that concern any of the +documented interfaces (REST API, plugin interfaces, ...). Example: ``1.x.y`` to ``2.0.0``. + +OctoPrint's version numbers are automatically generated using a customized version of +`versioneer `_ and depend on the selected git branch, nearest +git tag and commits. Unless a git tag is used for version number determination, the version number will +also contain the git hash within the local version identifier to allow for an exact determination of the +active code base (e.g. ``1.2.9.dev68+g46c7a9c``). Additionally, instances with active uncommitted changes +will contain ``.dirty`` in the local version identifier. + +.. rubric:: Footnotes + +.. [#f1] Up until 1.4.2, the PATCH version segment was the one increasing most often + due to OctoPrint's maintenance releases but with 1.5.0 this will *fully* adhere + to the concepts in semantic versioning mandating only bug fixes in patch releases. + Maintenance releases will henceforth increase the MINOR segment. diff --git a/docs/development/virtual_printer.rst b/docs/development/virtual_printer.rst index 479cdc198a..5e7a31e4d8 100644 --- a/docs/development/virtual_printer.rst +++ b/docs/development/virtual_printer.rst @@ -3,7 +3,7 @@ Setting up the virtual printer for debugging ============================================ -OctoPrint includes, by default, a virtual printer plugin. This plugin allows you to debug OctoPrint's serial +OctoPrint includes, by default, a :ref:`virtual printer plugin `. This plugin allows you to debug OctoPrint's serial communication without connecting to an actual printer. Furthermore, it is possible to create certain edge conditions that may be hard to reproduce with a real printer. @@ -12,33 +12,219 @@ that may be hard to reproduce with a real printer. Enabling the virtual printer ---------------------------- -The virtual printer is enabled by editing OctoPrint's config.yaml file. Details on the configuration file can -be found in the full :ref:`config.yaml documentation `. +The virtual printer can be enabled through its Settings pane. -The steps to take are as follows: +.. _sec-development-virtual-printer-config: -* Find config.yaml in the OctoPrint settings folder. Usually in ``~/.octoprint`` on Linux, in ``%APPDATA%/OctoPrint`` on Windows and in ``~/Library/Application Support/OctoPrint`` on MacOS. -* Add or extend the ``devel`` section with: +Virtual printer configuration options +------------------------------------- -.. code-block:: yaml +There many configuration options via ``config.yaml`` for the virtual printer that allow you to fine-tune its behavior: - devel: - virtualPrinter: - enabled: true +.. code-block:: yaml -* Restart OctoPrint. -* In the connection panel, a new option will appear in the Serial Port dropdown labeled ``VIRTUAL``. -* Select this option and click ``connect``. -* The virtual printer is now active. + plugins: -.. _sec-development-virtual-printer-config: + # Settings for the virtual printer + virtual_printer: -Virtual printer configuration options -------------------------------------- - -The config.yaml file has many configuration options for the virtual printer that allow you to fine-tune its behavior. + # Whether to enable the virtual printer and include it in the list of available serial connections. + # Defaults to false. + enabled: true -Please see the relevant :ref:`config.yaml section ` for the full details. + # Whether to send an additional "ok" after a resend request (like Repetier) + okAfterResend: false + + # Whether to force checksums and line number in the communication (like Repetier), if set to true + # printer will only accept commands that come with linenumber and checksum and throw an error for + # lines that don't. Defaults to false + forceChecksum: false + + # Whether to send "ok" responses with the line number that gets acknowledged by the "ok". Defaults + # to false. + okWithLinenumber: false + + # Number of extruders to simulate on the virtual printer. Map from tool id (0, 1, ...) to temperature + # in °C + numExtruders: 1 + + # Allows pinning certain hotends to a fixed temperature + pinnedExtruders: null + + # Whether to include the current tool temperature in the M105 output as separate T segment or not. + # + # True: > M105 + # < ok T:23.5/0.0 T0:34.3/0.0 T1:23.5/0.0 B:43.2/0.0 + # False: > M105 + # < ok T0:34.3/0.0 T1:23.5/0.0 B:43.2/0.0 + includeCurrentToolInTemps: true + + # Whether to include the selected filename in the M23 File opened response. + # + # True: > M23 filename.gcode + # < File opened: filename.gcode Size: 27 + # False: > M23 filename.gcode + # > File opened + includeFilenameInOpened: true + + # Whether the simulated printer should also simulate a heated bed or not + hasBed: true + + # Whether the simulated printer should also simulate a heated chamber or not + hasChamber: false + + # If enabled, reports the set target temperatures as separate messages from the firmware + # + # True: > M109 S220.0 + # < TargetExtr0:220.0 + # < ok + # > M105 + # < ok T0:34.3 T1:23.5 B:43.2 + # False: > M109 S220.0 + # < ok + # > M105 + # < ok T0:34.3/220.0 T1:23.5/0.0 B:43.2/0.0 + repetierStyleTargetTemperature: false + + # If enabled, uses repetier style resends, sending multiple resends for the same line + # to make sure nothing gets lost on the line + repetierStyleResends: false + + # If enabled, ok will be sent before a commands output, otherwise after or inline (M105) + # + # True: > M20 + # < ok + # < Begin file list + # < End file list + # False: > M20 + # < Begin file list + # < End file list + # < ok + okBeforeCommandOutput: false + + # If enabled, reports the first extruder in M105 responses as T instead of T0 + # + # True: > M105 + # < ok T:34.3/0.0 T1:23.5/0.0 B:43.2/0.0 + # False: > M105 + # < ok T0:34.3/0.0 T1:23.5/0.0 B:43.2/0.0 + smoothieTemperatureReporting: false + + # Settings related to the SD file list output + sdFiles: + # Whether M20 responses will include filesize or not + # + # True: + # False: + size: true + + # Whether M20 responses will include longname or not (only if size = true as well) + # + # True: + # False: + longname: false + + # Forced pause for retrieving from the outgoing buffer + throttle: 0.01 + + # Whether to send "wait" responses every "waitInterval" seconds when serial rx buffer is empty + sendWait: false + + # Interval in which to send "wait" lines when rx buffer is empty + waitInterval: 1 + + # Size of the simulated RX buffer in bytes, when it's full a send from OctoPrint's + # side will block + rxBuffer: 64 + + # Size of simulated command buffer, number of commands. If full, buffered commands will block + # until a slot frees up + commandBuffer: 4 + + # Whether to support the M112 command with simulated kill + supportM112: true + + # Whether to send messages received via M117 back as "echo:" lines + echoOnM117: true + + # Whether to simulate broken M29 behaviour (missing ok after response) + brokenM29: true + + # Whether F is supported as individual command + supportF: false + + # Firmware name to report (useful for testing firmware detection) + firmwareName: Virtual Marlin 1.0 + + # Simulate a shared nozzle + sharedNozzle: false + + # Send "busy" messages if busy processing something + sendBusy: false + + # Simulate a reset on connect + simulateReset: true + + # Lines to send on simulated reset + resetLines: + - start + - "Marlin: Virtual Marlin!" + - "SD card ok" + + # Initial set of prepared oks to use instead of regular ok (e.g. to simulate + # mis-sent oks). Can also be filled at runtime via the debug command prepare_ok + preparedOks: [] + + # Format string for ok response. + # + # Placeholders: + # - lastN: last acknowledged line number + # - buffer: empty slots in internal command buffer + # + # Example format string for "extended" ok format: + # ok N{lastN} P{buffer} + okFormatString: ok + + # Format string for M115 output. + # + # Placeholders: + # - firmare_name: The firmware name as defined in firmwareName + m115FormatString: "FIRMWARE_NAME: {firmware_name} PROTOCOL_VERSION:1.0" + + # Whether to include capability report in M115 output + m115ReportCapabilites: false + + # Capabilities to report if capability report is enabled + capabilities: + AUTOREPORT_TEMP: true + + # Simulated ambient temperature in °C + ambientTemperature: 21.3 + + # Response to M105 when there is a target + # Placeholders: + # - heater: The heater id (eg. T0, T1, B) + # - actual: The actual temperature of the heater + # - target: The target temperature of heater + m105TargetFormatString: {heater}:{actual:.2f}/ {target:.2f} + + # Response to M105 when there is no target + # Placeholders: + # - heater: The heater id (eg. T0, T1, B) + # - actual: The actual temperature of the heater + m105NoTargetFormatString: {heater}:{actual:.2f} + + # Enable virtual EEPROM + # If enabled, a file `eeprom.json` will be created in the plugin data folder + # to enable settings persistence across connections. Enables M500/1/2/4 commmands + # And a selection of other settings commands. Responses modeled on Marlin 2.0 + enable_eeprom: true + + # Support M503 + support_m503: true + + # Resend ratio to simulate noise on the line + resend_ratio: 0 .. _sec-development-virtual-printer-log: @@ -46,7 +232,7 @@ Log file -------- Once activated, the virtual printer will log all serial communication in the ``plugin_virtual_printer_serial.log`` file -that can be found in the OctoPrint settings folder. +that can be found in the OctoPrint logs folder. .. _sec-development-virtual-printer-debug: @@ -57,60 +243,71 @@ You can simulate certain conditions and communications through the terminal tab All commands start with ``!!DEBUG:`` and are followed by the command you want to execute. For instance, sending ``!!DEBUG:action_disconnect`` will disconnect the printer. Sending ``!!DEBUG`` without command will show a help -message with all the available commands. - -Action Triggers -............... - -``action_pause`` -Sends a "// action:pause" action trigger to the host. - -``action_resume`` -Sends a "// action:resume" action trigger to the host. - -``action_disconnect`` -Sends a "// action:disconnect" action trigger to the host. - -``action_custom [ ]`` -Sends a custom "// action: " action trigger to the host. - -Communication Errors -.................... - -``dont_answer`` -Will not acknowledge the next command. - -``go_awol`` -Will completely stop replying. - -``trigger_resend_lineno`` -Triggers a resend error with a line number mismatch - -``trigger_resend_checksum`` -Triggers a resend error with a checksum mismatch - -``drop_connection`` -Drops the serial connection - -``prepare_ok `` -Will cause to be enqueued for use, will be used instead of actual "ok" - -Reply Timing / Sleeping -....................... -``sleep `` -Sleep s - -``sleep_after `` -Sleeps s after each execution of - -``sleep_after_next `` -Sleeps s after execution of next - -Misc -.... - -``help`` -Show the available commands. - -``send `` -Sends back +message with all the available commands: + +.. code-block:: none + + OctoPrint Virtual Printer debug commands + + help + ? + | This help. + + # Action Triggers + + action_pause + | Sends a "// action:pause" action trigger to the host. + action_resume + | Sends a "// action:resume" action trigger to the host. + action_disconnect + | Sends a "// action:disconnect" action trigger to the + | host. + action_custom [ ] + | Sends a custom "// action: " + | action trigger to the host. + + # Communication Errors + + dont_answer + | Will not acknowledge the next command. + go_awol + | Will completely stop replying + trigger_resend_lineno + | Triggers a resend error with a line number mismatch + trigger_resend_checksum + | Triggers a resend error with a checksum mismatch + trigger_missing_checksum + | Triggers a resend error with a missing checksum + trigger_missing_lineno + | Triggers a "no line number with checksum" error w/o resend request + drop_connection + | Drops the serial connection + prepare_ok + | Will cause to be enqueued for use, + | will be used instead of actual "ok" + + # Reply Timing / Sleeping + + sleep + | Sleep s + sleep_after + | Sleeps s after each execution of + sleep_after_next + | Sleeps s after execution of next + + # SD printing + + start_sd + | Select and start printing file from SD + select_sd + | Select file from SD, don't start printing it yet. Use + | start_sd to start the print + cancel_sd + | Cancels an ongoing SD print + + # Misc + + send + | Sends back + reset + | Simulates a reset. Internal state will be lost. diff --git a/docs/events/index.rst b/docs/events/index.rst index 4e675c8f8f..a9b0300959 100644 --- a/docs/events/index.rst +++ b/docs/events/index.rst @@ -6,15 +6,6 @@ Events .. contents:: - -.. note:: - - With release of OctoPrint 1.1.0, the payload data has been harmonized, it is now a key-value-map for all events. - Additionally, the format of the placeholders in both system command and gcode command triggers has been changed to - accommodate for this new format. Last but not least, the way of specifying event hooks has changed, OctoPrint no longer - separates hooks into two sections (gcodeCommandTrigger and systemCommandTrigger) but instead event hooks are now typed - to indicate what to do with the command contained. - .. _sec-events-configuration: Configuration @@ -47,6 +38,13 @@ Example - event: PrintDone command: python ~/growl.py -t mygrowlserver -d "Completed {file}" -a OctoPrint -i http://raspi/Octoprint_logo.png type: system + - event: + - PrintStarted + - PrintFailed + - PrintDone + - PrintCancelled + command: python ~/growl.py -t mygrowlserver -d "Event {__eventname} ({name})" -a OctoPrint -i http://raspi/Octoprint_logo.png + type: system - event: Connected command: - M115 @@ -62,6 +60,7 @@ Placeholders You can use the following generic placeholders in your event hooks: * ``{__currentZ}``: the current Z position of the head if known, -1 if not available + * ``{__eventname}`` : the name of the event hook being triggered * ``{__filename}`` : name of currently selected file, or ``NO FILE`` if no file is selected * ``{__filepath}`` : path in origin location of currently selected file, or ``NO FILE`` if no file is selected * ``{__fileorigin}`` : origin of currently selected file, or ``NO FILE`` if no file is selected @@ -85,6 +84,12 @@ and its origin via the placeholder ``{origin}``. Available Events ================ +.. note:: + + Plugins may add additional events via the :ref:`octoprint.events.register_custom_events hook `. + +.. _sec-events-available_events-server: + Server ------ @@ -95,20 +100,53 @@ Shutdown The server is shutting down. ClientOpened - A client has connected to the web server. + A client has connected to the push socket. Payload: - * ``remoteAddress``: the remote address (IP) of the client that connected + * ``remoteAddress``: the remote address (IP) of the client that connected. On the push socket only available with + a valid login session. **Note:** Name changed in version 1.1.0 + .. versionchanged:: 1.1.0 + .. versionchanged:: 1.4.0 + +ClientAuthed + A client has authenticated a user session on the push socket. + + Payload: + + * ``remoteAddress``: the remote address (IP) of the client that authed. On the push socket only available with a + valid login session. + * ``username``: the name of the user who authed. On the push socket only available with a valid login session. + + .. versionadded:: 1.4.0 + ClientClosed - A client has disconnected from the webserver + A client has disconnected from the push socket. + + Payload: + + * ``remoteAddress``: the remote address (IP) of the client that disconnected. On the push socket only available + with a valid login session. + +UserLoggedIn + A user logged in. On the push socket only available with a valid login session with admin rights. + + Payload: + + * ``username``: the name of the user who logged in + + .. versionadded:: 1.4.0 + +UserLoggedOut + A user logged out. On the push socket only available with a valid login session with admin rights. Payload: + * ``username``: the name of the user who logged out - * ``remoteAddress``: the remote address (IP) of the client that disconnected + .. versionadded:: 1.4.0 ConnectivityChanged The server's internet connectivity changed @@ -118,12 +156,18 @@ ConnectivityChanged * ``old``: Old connectivity value (true for online, false for offline) * ``new``: New connectivity value (true for online, false for offline) + .. versionadded:: 1.3.5 + +.. _sec-events-available_events-printer_commmunication: + Printer communication --------------------- Connecting The server is attempting to connect to the printer. + .. versionadded:: 1.3.0 + Connected The server has connected to the printer. @@ -137,11 +181,17 @@ Disconnecting event might not always be sent when the server and printer get disconnected from each other. Do not depend on this for critical life cycle management. + .. versionadded:: 1.3.0 + Disconnected The server has disconnected from the printer Error - An error has occurred in the printer communication. + An unrecoverable error has been encountered, either as reported by the firmware (e.g. a thermal runaway) or + on the connection. + + Note that this event will not fire for error messages from the firmware that are handled (and as such recovered from) + either by OctoPrint or a plugin. Payload: @@ -156,22 +206,29 @@ PrinterStateChanged :func:`~octoprint.printer.PrinterInterface.get_state_id` for possible values. * ``state_string``: Text representation of the new state. + .. versionadded:: 1.3.0 + +.. _sec-events-available_events-file_handling: + File handling ------------- Upload - A file has been uploaded through the web interface. + A file has been uploaded through the :ref:`REST API `. Payload: * ``name``: the file's name * ``path``: the file's path within its storage location * ``target``: the target storage location to which the file was uploaded, either ``local`` or ``sdcard`` + * ``select``: whether the file will immediately be selected, as requested on the API by the corresponding parameter + * ``print``: whether the file will immediately start printing, as requested on the API by the corresponding parameter + * ``userdata``: optional ``userdata`` if provided on the API, will only be present if supplied in the upload request .. deprecated:: 1.3.0 - * ``file``: the file's path within its storage location + * ``file``: the file's path within its storage location. To be removed in 1.4.0. - Still available for reasons of backwards compatibility. Will be removed with 1.4.0. + .. versionchanged:: 1.4.0 FileAdded A file has been added to a storage. @@ -188,6 +245,8 @@ FileAdded A copied file triggers this for its new path. A moved file first triggers ``FileRemoved`` for its original path and then ``FileAdded`` for the new one. + .. versionadded:: 1.3.3 + FileRemoved A file has been removed from a storage. @@ -202,31 +261,37 @@ FileRemoved A moved file first triggers ``FileRemoved`` for its original path and then ``FileAdded`` for the new one. + .. versionadded:: 1.3.3 + FolderAdded A folder has been added to a storage. Payload: * ``storage``: the storage's identifier - * ``path``: the folders's path within its storage location - * ``name``: the folders's name + * ``path``: the folder's path within its storage location + * ``name``: the folder's name .. note:: A copied folder triggers this for its new path. A moved folder first triggers ``FolderRemoved`` for its original path and then ``FolderAdded`` for the new one. + .. versionadded:: 1.3.3 + FolderRemoved A folder has been removed from a storage. Payload: * ``storage``: the storage's identifier - * ``path``: the folders's path within its storage location - * ``name``: the folders's name + * ``path``: the folder's path within its storage location + * ``name``: the folder's name .. note:: A moved folder first triggers ``FolderRemoved`` for its original path and then ``FolderAdded`` for the new one. + .. versionadded:: 1.3.3 + UpdatedFiles A file list was modified. @@ -241,6 +306,7 @@ UpdatedFiles reasons of backwards compatibility and will also be sent on modification of ``printables``. It will however be removed with 1.4.0. + .. versionchanged:: 1.4.0 MetadataAnalysisStarted The metadata analysis of a file has started. @@ -253,9 +319,9 @@ MetadataAnalysisStarted .. deprecated:: 1.3.0 - * ``file``: the file's path within its storage location + * ``file``: the file's path within its storage location. To be removed in 1.4.0. - Still available for reasons of backwards compatibility. Will be removed with 1.4.0. + .. versionchanged:: 1.4.0 MetadataAnalysisFinished The metadata analysis of a file has finished. @@ -265,13 +331,13 @@ MetadataAnalysisFinished * ``name``: the file's name * ``path``: the file's path within its storage location * ``origin``: the file's origin storage location - * ``result``: the analysis result -- this is a python object currently only available for internal use + * ``result``: the analysis result -- this is a Python object currently only available for internal use .. deprecated:: 1.3.0 - * ``file``: the file's path within its storage location + * ``file``: the file's path within its storage location. To be removed in 1.4.0. - Still available for reasons of backwards compatibility. Will be removed with 1.4.0. + .. versionchanged:: 1.4.0 FileSelected A file has been selected for printing. @@ -284,10 +350,10 @@ FileSelected .. deprecated:: 1.3.0 - * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``) - * ``filename``: the file's name + * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``). To be removed in 1.4.0. + * ``filename``: the file's name. To be removed in 1.4.0. - Still available for reasons of backwards compatibility. Will be removed with 1.4.0. + .. versionchanged:: 1.4.0 FileDeselected No file is selected any more for printing. @@ -302,6 +368,8 @@ TransferStarted **Note:** Name changed in version 1.1.0 + .. versionchanged:: 1.1.0 + TransferDone A file transfer to the printer's SD has finished. @@ -311,6 +379,8 @@ TransferDone * ``local``: the file's name as stored locally * ``remote``: the file's name as stored on SD +.. _sec-events-available_events-printing: + Printing -------- @@ -322,13 +392,16 @@ PrintStarted * ``name``: the file's name * ``path``: the file's path within its storage location * ``origin``: the origin storage location of the file, either ``local`` or ``sdcard`` + * ``size``: the file's size in bytes (if available) + * ``owner``: the user who started the print job (if available) + * ``user``: the user who started the print job (if available) .. deprecated:: 1.3.0 - * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``) - * ``filename``: the file's name + * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``). To be removed in 1.4.0. + * ``filename``: the file's name. To be removed in 1.4.0. - Still available for reasons of backwards compatibility. Will be removed with 1.4.0. + .. versionchanged:: 1.4.0 PrintFailed A print failed. @@ -338,13 +411,17 @@ PrintFailed * ``name``: the file's name * ``path``: the file's path within its storage location * ``origin``: the origin storage location of the file, either ``local`` or ``sdcard`` + * ``size``: the file's size in bytes (if available) + * ``owner``: the user who started the print job (if available) + * ``time``: the elapsed time of the print when it failed, in seconds (float) + * ``reason``: the reason the print failed, either ``cancelled`` or ``error`` .. deprecated:: 1.3.0 - * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``) - * ``filename``: the file's name + * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``). To be removed in 1.4.0. + * ``filename``: the file's name. To be removed in 1.4.0. - Still available for reasons of backwards compatibility. Will be removed with 1.4.0. + .. versionchanged:: 1.4.0 PrintDone A print completed successfully. @@ -354,24 +431,46 @@ PrintDone * ``name``: the file's name * ``path``: the file's path within its storage location * ``origin``: the origin storage location of the file, either ``local`` or ``sdcard`` + * ``size``: the file's size in bytes (if available) + * ``owner``: the user who started the print job (if available) * ``time``: the time needed for the print, in seconds (float) .. deprecated:: 1.3.0 - * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``) - * ``filename``: the file's name + * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``). To be removed in 1.4.0. + * ``filename``: the file's name. To be removed in 1.4.0. + + .. versionchanged:: 1.4.0 + +PrintCancelling + The print is about to be cancelled. + + Payload: + + * ``name``: the file's name + * ``path``: the file's path within its storage location + * ``origin``: the origin storage location of the file, either ``local`` or ``sdcard`` + * ``size``: the file's size in bytes (if available) + * ``owner``: the user who started the print job (if available) + * ``user``: the user who cancelled the print job (if available) + * ``firmwareError``: the firmware error that caused cancelling the print job, if any - Still available for reasons of backwards compatibility. Will be removed with 1.4.0. + .. versionadded:: 1.3.7 PrintCancelled - The print has been cancelled via the cancel button. + The print has been cancelled. Payload: * ``name``: the file's name * ``path``: the file's path within its storage location * ``origin``: the origin storage location of the file, either ``local`` or ``sdcard`` - * ``position``: the print head position at the time of cancelling, if available + * ``size``: the file's size in bytes (if available) + * ``owner``: the user who started the print job (if available) + * ``time``: the elapsed time of the print when it was cancelled, in seconds (float) + * ``user``: the user who cancelled the print job (if available) + * ``position``: the print head position at the time of cancelling (if available, not available if recording of the + position on cancel is disabled) * ``position.x``: x coordinate, as reported back from the firmware through `M114` * ``position.y``: y coordinate, as reported back from the firmware through `M114` * ``position.z``: z coordinate, as reported back from the firmware through `M114` @@ -385,10 +484,10 @@ PrintCancelled .. deprecated:: 1.3.0 - * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``) - * ``filename``: the file's name + * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``). To be removed in 1.4.0. + * ``filename``: the file's name. To be removed in 1.4.0. - Still available for reasons of backwards compatibility. Will be removed with 1.4.0. + .. versionchanged:: 1.4.0 PrintPaused The print has been paused. @@ -398,7 +497,11 @@ PrintPaused * ``name``: the file's name * ``path``: the file's path within its storage location * ``origin``: the origin storage location of the file, either ``local`` or ``sdcard`` - * ``position``: the print head position at the time of pausing, if available + * ``size``: the file's size in bytes (if available) + * ``owner``: the user who started the print job (if available) + * ``user``: the user who paused the print job (if available) + * ``position``: the print head position at the time of pausing (if available, not available if the recording of + the position on pause is disabled or the pause is completely handled by the printer's firmware) * ``position.x``: x coordinate, as reported back from the firmware through `M114` * ``position.y``: y coordinate, as reported back from the firmware through `M114` * ``position.z``: z coordinate, as reported back from the firmware through `M114` @@ -412,10 +515,10 @@ PrintPaused .. deprecated:: 1.3.0 - * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``) - * ``filename``: the file's name + * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``). To be removed in 1.4.0. + * ``filename``: the file's name. To be removed in 1.4.0. - Still available for reasons of backwards compatibility. Will be removed with 1.4.0. + .. versionchanged:: 1.4.0 PrintResumed The print has been resumed. @@ -425,13 +528,46 @@ PrintResumed * ``name``: the file's name * ``path``: the file's path within its storage location * ``origin``: the origin storage location of the file, either ``local`` or ``sdcard`` + * ``size``: the file's size in bytes (if available) + * ``owner``: the user who started the print job (if available) + * ``user``: the user who resumed the print job (if available) .. deprecated:: 1.3.0 - * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``) - * ``filename``: the file's name + * ``file``: the file's full path on disk (``local``) or within its storage (``sdcard``). To be removed in 1.4.0. + * ``filename``: the file's name. To be removed in 1.4.0. - Still available for reasons of backwards compatibility. Will be removed with 1.4.0. + .. versionchanged:: 1.4.0 + +GcodeScript${ScriptName}Running + A custom :ref:`GCODE script ` has started running. + + Payload: + + * ``name``: the file's name + * ``path``: the file's path within its storage location + * ``origin``: the origin storage location of the file, either ``local`` or ``sdcard`` + * ``size``: the file's size in bytes (if available) + * ``owner``: the user who started the print job (if available) + * ``time``: the time needed for the print, in seconds (float) + + .. versionadded:: 1.6.0 + +GcodeScript${ScriptName}Finished + A custom :ref:`GCODE script ` has finished running. + + Payload: + + * ``name``: the file's name + * ``path``: the file's path within its storage location + * ``origin``: the origin storage location of the file, either ``local`` or ``sdcard`` + * ``size``: the file's size in bytes (if available) + * ``owner``: the user who started the print job (if available) + * ``time``: the time needed for the print, in seconds (float) + + .. versionadded:: 1.6.0 + +.. _sec-events-available_events-gcode_processing: GCODE processing ---------------- @@ -471,6 +607,11 @@ Eject EStop An ``M112`` was sent to the printer through OctoPrint (not triggered when printing from SD!) +FilamentChange + An ``M600``, ``M701`` or ``M702`` was sent to the printer through OctoPrint (not triggered when printing from SD!) + + .. versionadded:: 1.7.0 + PositionUpdate The response to an ``M114`` was received by OctoPrint. The payload contains the current position information parsed from the response and (in the case of the selected tool ``t`` and the current feedrate ``f``) tracked @@ -485,6 +626,8 @@ PositionUpdate * ``t``: last tool selected *through OctoPrint* * ``f``: last feedrate for move commands ``G0``, ``G1`` or ``G28`` sent *through OctoPrint* + .. versionadded:: 1.3.0 + ToolChange A tool change command was sent to the printer. The payload contains the former current tool index and the new current tool index. @@ -494,6 +637,36 @@ ToolChange * ``old``: old tool index * ``new``: new tool index + .. versionadded:: 1.3.5 + +CommandSuppressed + A command was suppressed by OctoPrint due to according configuration and will not be + sent to the printer. + + Payload: + + * ``command``: the command that was suppressed + * ``message``: a message containing an explanation of the command suppression + * ``severity``: a severity level, either ``warn`` or ``info`` - ``warn`` indicates + that the command was suppressed probably due to a misconfiguration either inside + OctoPrint or the firmware and that it should be investigated by the user + + .. versionadded:: 1.5.0 + +InvalidToolReported + The firmware reported a tool as invalid upon trying to select it. It has thus been marked + as invalid and further attempts to select said tool will result in the tool command + to get suppressed (and ``SuppressedCommand`` to be generated). + + Payload: + + * ``tool``: the tool number that was reported as invalid by the firmware + * ``fallback``: the tool number that OctoPrint will revert to + + .. versionadded:: 1.5.0 + +.. _sec-events-available_events-timelapses: + Timelapses ---------- @@ -517,6 +690,8 @@ CaptureFailed * ``file``: the name of the image file that should have been saved * ``error``: the error that was caught + .. versionadded:: 1.3.0 + MovieRendering The timelapse movie has started rendering. @@ -548,6 +723,8 @@ MovieFailed returned a non-0 return code, ``no_frames`` if no frames were captured that could be rendered to a timelapse, or ``unknown`` for any other reason of failure to render. +.. _sec-events-available_events-slicing: + Slicing ------- @@ -556,6 +733,7 @@ SlicingStarted Payload: + * ``slicer``: the used slicer * ``stl``: the STL's filename * ``stl_location``: the STL's location * ``gcode``: the sliced GCODE's filename @@ -567,6 +745,7 @@ SlicingDone Payload: + * ``slicer``: the used slicer * ``stl``: the STL's filename * ``stl_location``: the STL's location * ``gcode``: the sliced GCODE's filename @@ -579,6 +758,7 @@ SlicingCancelled Payload: + * ``slicer``: the used slicer * ``stl``: the STL's filename * ``stl_location``: the STL's location * ``gcode``: the sliced GCODE's filename @@ -589,6 +769,7 @@ SlicingFailed Payload: + * ``slicer``: the used slicer * ``stl``: the STL's filename * ``stl_location``: the STL's location * ``gcode``: the sliced GCODE's filename @@ -603,14 +784,18 @@ SlicingProfileAdded * ``slicer``: the slicer for which the profile was added * ``profile``: the profile that was added + .. versionadded:: 1.2.12 + SlicingProfileModified - A new slicing profile was modified. + A slicing profile was modified. Payload: * ``slicer``: the slicer for which the profile was modified * ``profile``: the profile that was modified + .. versionadded:: 1.2.12 + SlicingProfileDeleted A slicing profile was deleted. @@ -619,8 +804,32 @@ SlicingProfileDeleted * ``slicer``: the slicer for which the profile was deleted * ``profile``: the profile that was deleted + .. versionadded:: 1.2.12 + +.. _sec-events-available_events-settings: + Settings -------- SettingsUpdated - The internal settings were updated. + The settings were updated via the REST API. + + This event may also be triggered if calling code of :py:class:`octoprint.settings.Settings.save` or + :py:class:`octoprint.plugin.PluginSettings.save` sets the ``trigger_event`` parameter to ``True``. + + .. versionadded:: 1.2.0 + +.. _sec-events-available_events-printer_profile: + +Printer Profile +--------------- + +PrinterProfileModified + A printer profile was modified. + + Payload: + + * ``identifier``: the identifier of the modified printer profile + + .. versionadded:: 1.3.12 + diff --git a/docs/features/accesscontrol.rst b/docs/features/accesscontrol.rst index dd59bd6b85..1c3ee25831 100644 --- a/docs/features/accesscontrol.rst +++ b/docs/features/accesscontrol.rst @@ -3,8 +3,31 @@ Access Control ============== -When Access Control is enabled, anonymous users (not logged in) will only see -the read-only parts of the UI which are the following: +.. versionchanged:: 1.5.0 + +OctoPrint's bundled access control feature allows granular permission control +over which users or user groups are allowed to access which parts of OctoPrint. + +The default permissions will deny any kind of access to anonymous (not logged in) +users out of the box. + +.. warning:: + + Please note that OctoPrint does *not* control the webcam and merely embeds it, and + thus also cannot limit access to it. If an anonymous user correctly guesses the + webcam URL, they will thus be able to see it. + +Upon first start a configuration wizard is provided which allows configuration +of the first administrator account to be used for OctoPrint. After initial setup, +you can then create more users and groups under Settings > Access Control for +customisation of the granular permission system. + +The predefined "Guests" group can be used to configure default permissions of anonymous +users, that is those who have not logged in. By default, no permissions are granted to +these users. + +A predefined "Read-only Access" group with no users is configured which by default grants +read-only access to the following parts of the UI to any users assigned to this group: * printer state * available gcode files and stats (upload is disabled) @@ -16,54 +39,216 @@ the read-only parts of the UI which are the following: * any components provided through plugins which are enabled for anonymous users -Logged in users will get access to everything besides the Settings and System -Commands, which are admin-only. +Another predefined "Operator" group is the default group for newly created users and +by default gives access to all aspects of OctoPrint that involve regular printer +operation. It matches the old "user" role from OctoPrint prior to 1.4.0. -If Access Control is disabled, everything is directly accessible. **That also -includes all administrative functionality as well as full control over the -printer!** - -Upon first start a configuration wizard is provided which allows configuration -of the first administrator account or alternatively disabling Access Control -(which is **NOT** recommended for systems that are directly accessible via the -Internet!). +Finally, the predefined "Admins" group gives full admin access to the platform. You should +be careful of who you put into that. It matches the old "admin" role from OctoPrint prior +to 1.4.0. .. hint:: If you plan to have your OctoPrint instance accessible over the internet, - **always enable Access Control**. + **please use additional security measures** and ideally **don't make it accessible to + everyone over the internet but instead use a VPN** or at the very least + HTTP basic authentication on a layer above OctoPrint. Unless you are using a VPN + **please do not** enable any permissions for the Guest group or the auto-login feature + described below. + + A physical device that includes heaters and stepper motors really should not be + publicly reachable by everyone with an internet connection, even with access + control enabled. + +.. _sec-features-access_control-autologin: + +Autologin +--------- + +While access control cannot be disabled as of OctoPrint 1.5+, the Autologin feature can +be used to bypass authentication for hosts on the network(s) that you trust. + +Starting with OctoPrint 1.5.0, OctoPrint makes enabled access control mandatory. This +might be an inconvience for some who run OctoPrint in an isolated setup where a login is +not required to ensure security, at a benefit for a huge number of users out there who +continue to underestimate or simply ignore the risk of keeping their OctoPrint instance +unsecured and then happily exposing it on the public internet. + +That being said, even as far back as OctoPrint 1.0.0 (released in 2013) there has existed +a way to have OctoPrint automatically log you in, if you connect from a trusted local +network address. This functionality has not been exposed on the UI, and for now also won't +be (to make it a bit harder to once again create an insecure setup for those who simply +won't listen to common sense), but it's easy to set up with a bit of configuration +editing. + +When set up properly, it will make sure to automatically log you in as a configured user +whenever you connect from a device on your local network. To get back pretty much the same +behaviour as with disabled access control, you'll only need to create a single (admin) +account and then set up autologin for it. + + +.. warning:: + + **Do not do this if you cannot trust EVERYONE on your local network.** And that really + means mean everyone. If you ignore this and then someone takes over your OctoPrint + instance, installs malware on it and makes your printer print an endless stream of + benchies, that's on you. + +.. _sec-features-access_control-autologin-gather_config_info: + +Gather configuration information +................................ + +You can configure Autologin via a plugin (the easy way), or manually (the hard way), but +in either case you will need to specify which user should be automatically logged in, and +which hosts are permitted access this way. + +**Improperly setting this subnet option can lead to the compromise of your system, or even +your entire network. Proceed with extreme caution.** + +The subnet to use is usually the IP address range of your LAN, which sounds scary but +actually isn't. Just `figure out your PC's IP address and subnet mask `_ +and then combine both with a / in between. + +On OctoPi (or another Linux distribution) you can use the follwing command: + +.. code-block:: + + ip route | grep -P 'eth0|wlan0' | awk '{print $1}' + +Or, for IPv6, use this: + +.. code-block:: + + ip -6 route | grep -P 'eth0|wlan0' | awk '{print $1}' + +This will be what you set as the subnet in the plugin, or where it says +```` below on the manual configuration instructions. + +Example: Your PC has an IP address of ``192.168.23.42`` and a subnet mask of +``255.255.255.0``. Your address range is ``192.168.23.42/255.255.255.0``. + +.. _sec-features-access_control-autologin-plugin: + +The easy way: Using the OctoPrint-AutoLoginConfig plugin +........................................................ + +The easist way to configure AutoLogin is to install the +`OctoPrint-AutoLoginConfig plugin `_ +via the plugin manager. + +Open its settings and follow the instructions on the screen. + +.. _sec-features-access_control-autologin-manual: + +The hard way: Manual editing of config.yaml +........................................... + +Preparation +*********** + +First of all, read :ref:`the YAML primer `. You +will have to edit OctoPrint's main configuration file, and thus should make sure +you understand at least roughly how things work and that you should keep your +hands off the Tab key. If you don't, you might break your config file, and +while the steps include making a backup, this still can be easily avoided by +learning about the DOs and DONTs first. + +Then, take a look at :ref:`the docs on config.yaml ` +on where to find that central configuration file of OctoPrint. + +Configuration +************* + +Ready? Let's do some editing then. I'll outline what to do and where first, and then +further down there's also a dedicated list of steps for OctoPi specifically. + +1. Shutdown OctoPrint +2. Make a backup of your config.yaml +3. Open it in a text editor (e.g. nano). Right at the very top it'll say something like + this: + + .. code-block:: yaml + + accessControl: + salt: aabbccddee1234523452345 + + Edit this, adding lines so it looks like this (making absolutely sure not to touch the + salt line): + + .. code-block:: yaml + + accessControl: + salt: aabbccddee1234523452345 + autologinLocal: true + autologinAs: "" + localNetworks: + - "127.0.0.0/8" + - "::1/128" + - "" + +4. Restart OctoPrint, check that everything works. + +This will automatically log you in as the user you specified whenver you connect to +OctoPrint from an address in the address range (e.g. a device on your local network). + +OctoPi specific steps +********************* + +If you are running OctoPi you will have to SSH into your Raspberry Pi. Then issue +the following commands: + +1. ``sudo service octoprint stop`` +2. ``cp ~/.octoprint/config.yaml ~/.octoprint/config.yaml.back`` +3. ``nano ~/.octoprint/config.yaml, make the edits as described above`` +4. ``sudo service octoprint start`` + +If something went wrong, you can restore the config backup with + +.. code-block:: + + cp ~/.octoprint/config.yaml.back ~/.octoprint/config.yaml + + +If you are using a VPN and your setup ABSOLUTELY REQUIRES disabling internal OctoPrint access controls +...................................................................................................... + +.. warning:: + + You probably shouldn't do this, EVER. There are usually other options. Don't even + THINK about it, unless you have a VPN layer for security. Only consider proceeding + with this configuration after exhausting ALL other possibilities, and even then, you + should think long and hard about whether this is a good idea. You almost certainly + don't need or want to do this. + +While access controls can no longer be disabled in OctoPrint 1.5+, this can be +approximated by an Autologin configuration that automatically logs in all users, that is +by using subnets that match all possible IP addresses. By specifying the ``0.0.0.0/0`` +subnet (for IPv4) and ``::/0`` for IPv6 in the AutoLogin configuration, you can achieve +this. This configuration is permitted, but highly, highly discouraged. -.. _sec-features-access_control-rerunning_wizard: +Please don't do this. You will almost certainly regret it. You alone are responsible for +your actions. -Rerunning the wizard --------------------- +.. _sec-features-access_control-hooks: -In case Access Control was disabled in the configuration wizard, it is -possibly to re-run it by editing ``config.yaml`` [#f1]_ and setting ``firstRun`` -in the ``server`` section and ``enabled`` in the ``accessControl`` section to -``true``: +Available Extension Hooks +------------------------- -.. code-block-ext:: yaml +There are two hooks for plugins to utilize in order to +add new configurable permissions into the system and/or adjust the styling of the +login dialog. - accessControl: - enabled: true - # ... - server: - firstRun: true +.. _sec-features-access_control-hooks-permissions: -Then restart the server and connect to the web interface - the wizard should -be shown again. +octoprint.access.permissions +............................ -.. note:: +See :ref:`here `. - If user accounts were created prior to disabling Access Control and those - user accounts are not to be used any more, remove ``.octoprint/users.yaml``. - If you don't remove this file, the above changes won't lead to the - configuration being shown again, instead Access Control will just be - enabled using the already existing login data. This is to prevent you from - resetting access control by accident. +.. _sec-features-access_control-hooks-loginui: -.. rubric:: Footnotes +octoprint.theming.login +....................... -.. [#f1] For Linux that will be ``~/.octoprint/config.yaml``, for Windows it will be ``%APPDATA%/OctoPrint/config.yaml`` and for - Mac ``~/Library/Application Support/OctoPrint/config.yaml`` +See :ref:`here `. diff --git a/docs/features/action_commands.rst b/docs/features/action_commands.rst index 82591cf9eb..b3d298e2e5 100644 --- a/docs/features/action_commands.rst +++ b/docs/features/action_commands.rst @@ -21,16 +21,62 @@ Action commands are a feature defined for the GCODE based RepRap communication p OctoPrint out of the box supports handling of the above mentioned commands: +start + When this command is received from the printer, a job is currently selected and *not* active, OctoPrint + will start it just like if the "Start"/"Restart" button had been clicked. + + .. versionadded:: 1.5.0 + +cancel + When this command is received from the printer, OctoPrint will cancel a current print job just like if the + "Cancel" button had been clicked. + pause - When this command is received from the printer, OctoPrint will pause streaming of a current print job just like if the + When this command is received from the printer, OctoPrint will pause a current print job just like if the "Pause" button had been clicked. +paused + When this command is received from the printer, OctoPrint will pause a current print job but *without* triggering + any GCODE scripts or sending SD print control commands to the printer. This might be interesting for firmware + that wants to signal to OctoPrint that a print should be paused but without any control interference from + OctoPrint, e.g. in case of a filament change fully managed by the firmware. + resume - When this command is received from the printer, OctoPrint will resume streaming of a current print job just like if + When this command is received from the printer, OctoPrint will resume a current print job just like if the "Resume" button had been clicked. +resumed + When this command is received from the printer, OctoPrint will resume a current print job but *without* triggering + any GCODE scripts or sending SD print control commands to the printer. This might be interesting for firmware + that wants to signal to OctoPrint that a print should be resumed but without any control interference from + OctoPrint, e.g. in case of a filament change fully managed by the firmware. + disconnect - When this command is Received from the printer, OctoPrint will immediately disconnect from it. + When this command is received from the printer, OctoPrint will immediately disconnect from it. + +sd_inserted + When this command is received from the printer, OctoPrint will assume an SD card is present in the printer, + set the corresponding internal state flags and send a file list request. This command is only recognized + if SD support is enabled in OctoPrint. + + .. versionadded:: 1.6.0 + +sd_ejected + When this command is received from the printer, OctoPrint will assume the SD card has been removed from + the printer and clear the corresponding internal state flags. This command is only recognized + if SD support is enabled in OctoPrint. + + .. versionadded:: 1.6.0 + +sd_updated + When this command is received from the printer, OctoPrint will assume something on the SD card in the + printer has changed and trigger a file list request. This command is only recognized + if SD support is enabled in OctoPrint. + + .. versionadded:: 1.6.0 + +If the bundled :ref:`Action Command Prompt Support Plugin ` is enabled (which +should be the case by default), OctoPrint will also interactive dialog creation through its :ref:`supported commands `. Support for additional commands may be added by plugins by implementing a handler for the :ref:`octoprint.comm.protocol.action ` hook. diff --git a/docs/features/atcommands.rst b/docs/features/atcommands.rst new file mode 100644 index 0000000000..03d4b863f3 --- /dev/null +++ b/docs/features/atcommands.rst @@ -0,0 +1,32 @@ +.. _sec-features-atcommands: + +@ Commands +========== + +.. versionadded:: 1.3.7 + +@ commands (also known as host commands elsewhere) are special commands you may include in GCODE files streamed +through OctoPrint to your printer or send as part of GCODE scripts, through the Terminal Tab, the API or plugins. +Contrary to other commands they will never be sent to the printer but instead trigger functions inside OctoPrint. + +They are always of the form ``@[ ]``, e.g. ``@pause`` or ``@custom_command with some parameters``. + +Out of the box OctoPrint supports handling of these commands starting with version 1.3.7: + +@cancel + OctoPrint will cancel the current print job like if the "Cancel" button had been clicked. This command doesn't + take any parameters. + +@abort + Same as ``cancel``. + +@pause + OctoPrint will pause the current print job just like if the "Pause" button had been clicked. This command doesn't + take any parameters. + +@resume + OctoPrint will resume the current print job just like if the "Resume" button had been clicked. This command doesn't + take any parameters. + +Support for additional commands may be added by plugins by implementing a handler for one of the +:ref:`octoprint.comm.protocol.atcommand ` hooks. diff --git a/docs/features/custom_controls.rst b/docs/features/custom_controls.rst index a953617e9e..522d392cb5 100644 --- a/docs/features/custom_controls.rst +++ b/docs/features/custom_controls.rst @@ -166,11 +166,16 @@ Controls clicked. This allows to override the direct sending of the command or commands to the printer with more sophisticated behaviour. The JavaScript snippet is ``eval``'d and processed in a context where the control it is part of is provided as local variable ``data`` and the ``ControlViewModel`` is available as ``self``. + * - ``additionalClasses`` + - (Optional) Additional classes to apply to the button of a ``command``, ``commands``, ``script`` or ``javascript`` + control, other than the default ``btn``. Can be used to visually style the button, e.g. set to ``btn-danger`` to + turn the button red. * - ``enabled`` - (Optional) A JavaScript snippet returning either ``true`` or ``false`` determining whether the control - should be enabled or not. This allow to override the default logic for this (disabled if printer is offline - or currently printing). The JavaScript snippet is ``eval``'d and processed in a context where the control - it is part of is provided as local variable ``data`` and the ``ControlViewModel`` is available as ``self``. + should be enabled or not. This allows to override the default logic for the enable state + of the control (disabled if printer is offline). The JavaScript snippet is ``eval``'d and processed + in a context where the control it is part of is provided as local variable ``data`` and the + ``ControlViewModel`` is available as ``self``. * - ``input`` - (Optional) A list of definitions of input parameters for a ``command`` or ``commands``, to be rendered as additional input fields. ``command``/``commands`` may contain placeholders to be replaced by the values obtained @@ -206,20 +211,31 @@ Controls * - ``input.slider.step`` - (Optional) Step size per slider "tick", defaults to 1. * - ``regex`` - - (Optional) A `regular expression `_ to + - (Optional) A :ref:`regular expression ` to match against lines received from the printer to retrieve information from it (e.g. specific output). Together with ``template`` this allows rendition of received data from the printer within the UI. + + **Please also read the note below**. * - ``template`` - (Optional) A template to use for rendering the match of ``regex``. May contain placeholders in - `Python Format String Syntax `_ for either named + :ref:`Python Format String Syntax ` for either named groups within the regex (e.g. ``Temperature: {temperature}`` for a regex ``T:\s*(?P\d+(\.\d*)``) or positional groups within the regex (e.g. ``Position: X={0}, Y={1}, Z={2}, E={3}`` for a regex ``X:([0-9.]+) Y:([0-9.]+) Z:([0-9.]+) E:([0-9.]+)``). + + **Please also read the note below**. * - ``confirm`` - (Optional) A text to display to the user to confirm his button press. Can be used with sensitive custom controls like changing EEPROM values in order to prevent accidental clicks. The text will be displayed in a confirmation dialog like in :numref:`fig-configuration-customcontrols-confirm`. +.. note:: + + ``regex`` and ``template`` are only supported for controls that are defined through + ``config.yaml``. These control properties aren't supported for controls added only in + the frontend by the :ref:`getAdditionalControls ` + view model callback. + .. _fig-configuration-customcontrols-confirm: .. figure:: ../images/features-custom_controls-confirm.png :align: center diff --git a/docs/features/gcode_scripts.rst b/docs/features/gcode_scripts.rst index 4be810ea11..aa8fb1329d 100644 --- a/docs/features/gcode_scripts.rst +++ b/docs/features/gcode_scripts.rst @@ -11,20 +11,14 @@ is clicked. Unless :ref:`configured otherwise `, OctoPrint expects scripts to be located in the ``scripts/gcode`` folder in OctoPrint configuration directory (per default ``~/.octoprint`` on Linux, ``%APPDATA%\OctoPrint`` -on Windows and ``~/Library/Application Support/OctoPrint`` on MacOS). +on Windows and ``~/Library/Application Support/OctoPrint`` on macOS). These GCODE scripts are backed by the templating engine Jinja2, allowing more than just simple "send-as-is" scripts but making use of a full blown templating language in order to create your scripts. To this end, OctoPrint injects some variables into the :ref:`template rendering context ` as described below. -You can find the docs on the Jinja templating engine as used in OctoPrint at `jinja.octoprint.org `_. - -.. note:: - - Due to backwards compatibility issues with Jinja versions 2.9+, OctoPrint currently only supports Jinja 2.8. For this - reason use the template documentation at `jinja.octoprint.org `_ instead of the - documentation of current stable Jinja versions. +You can find the docs on the Jinja templating engine as used in OctoPrint `here `_. .. _sec-features-gcode_scripts-predefined: @@ -44,11 +38,22 @@ The following GCODE scripts are sent by OctoPrint automatically: * ``afterPrintDone``: Sent just after a print job finished. Defaults to an empty script. * ``afterPrintPaused``: Sent just after a print job was paused. Defaults to an empty script. * ``beforePrintResumed``: Sent just before a print job is resumed. Defaults to an empty script. + * ``beforeToolChange``: Sent just before a tool change command (``Tn``) is issued. + * ``afterToolChange``: Sent just after a tool change command (``Tn``) is issued .. note:: Plugins may extend these scripts through :ref:`a hook `. +.. _sec-features-gcode_scripts-events: + +Events +------ + +Every GCODE script that is executed will emit two events. The event name will start with 'GcodeScript' followed by the capitalized name +of the script. When ``afterPrintDone`` has started the event will be ``GcodeScriptAfterPrintDoneRunning`` and once it has completed the last event +will be ``GcodeScriptAfterPrintDoneFinished``. You can find more details in the :ref:`Events ` documentation. + .. _sec-features-gcode_scripts-snippets: Snippets @@ -66,8 +71,9 @@ Context All GCODE scripts have access to the following template variables through the template context: - * ``printer_profile``: The currently selected Printer Profile, including - information such as the extruder count, the build volume size, the filament diameter etc. + * ``printer_profile``: The currently selected :ref:`Printer Profile `, including + information such as the extruder count, the build volume size, the filament diameter etc. The individual properties + follow the common data model for :ref:`printer profiles `. * ``last_position``: Last position reported by the printer via `M114` (might be unset if no `M114` was sent so far!). Consists of ``x``, ``y``, ``z`` and ``e`` coordinates as received by the printer and tracked values for ``f`` and current tool ``t`` taken from commands sent through OctoPrint. All of these coordinates might be ``None`` if no @@ -75,11 +81,12 @@ All GCODE scripts have access to the following template variables through the te * ``last_temperature``: Last actual and target temperature reported for all available tools and if available the heated bed. This is a dictionary of key-value pairs. The keys are the indices of the available tools (``0``, ``1``, ...) and ``b`` for the heated bed. The values are a dictionary consisting of ``actual`` and ``target`` keys mapped - to the corresponding temperature in degrees celsius. Note that not all tools your printer has must necessarily be + to the corresponding temperature in degrees Celsius. Note that not all tools your printer has must necessarily be present here, neither must the heated bed - it depends on whether OctoPrint has values for a tool or the bed. Also note that ``actual`` and ``target`` might be ``None``. * ``script``: An object wrapping the script's type (``gcode``) and name (e.g. ``afterPrintCancelled``) as ``script.type`` and ``script.name`` respectively. + * ``plugins``: An object containing variables provided by plugins (e.g ``plugins.myplugin.myvariable``) There are a few additional template variables available for the following specific scripts: @@ -103,6 +110,11 @@ There are a few additional template variables available for the following specif * ``cancel_temperature``: Last known temperature values when the print was cancelled. See ``last_temperature`` above for the structure to expect here. + * ``beforeToolChange`` and ``afterToolChange`` + + * ``tool.old``: The number of the previous tool + * ``tool.new``: The number of the new tool + .. warning:: @@ -156,9 +168,13 @@ The ``disable_hotends`` snippet is defined as follows: .. code-block:: jinja :caption: Default ``disable_hotends`` snippet + {% if printer_profile.extruder.sharedNozzle %} + M104 T0 S0 + {% else %} {% for tool in range(printer_profile.extruder.count) %} M104 T{{ tool }} S0 {% endfor %} + {% endif %} The ``disable_bed`` snippet is defined as follows: @@ -185,7 +201,7 @@ More nifty pause and resume ........................... If you do not have a multi-extruder setup, aren't printing from SD and have "Log position on pause" enabled under -Settings > Serial > Advanced options, the following ``afterPrintPaused`` and +Settings > Serial Connection > Behaviour > Pausing, the following ``afterPrintPaused`` and ``beforePrintResumed`` scripts might be interesting for you. With something like them in place, OctoPrint will move your print head out of the way to a safe rest position (here ``G1 X0 Y0``, you might want to adjust that) on pause and move it back to the persisted pause position on resume, making sure to also reset the extruder and feedrate. @@ -245,7 +261,6 @@ to the persisted pause position on resume, making sure to also reset the extrude .. seealso:: - `Jinja Template Designer Documentation `_ + `Jinja Template Designer Documentation `_ Jinja's Template Designer Documentation describes the syntax and semantics of the template language used - also by OctoPrint's GCODE scripts. Linked here are the docs for Jinja 2.8.1, which OctoPrint still - relies on for backwards compatibility reasons. + also by OctoPrint's GCODE scripts. diff --git a/docs/features/index.rst b/docs/features/index.rst index ebbf488218..37c056f2f3 100644 --- a/docs/features/index.rst +++ b/docs/features/index.rst @@ -15,5 +15,6 @@ Features custom_controls.rst gcode_scripts.rst action_commands.rst + atcommands.rst plugins.rst safemode.rst diff --git a/docs/features/plugins.rst b/docs/features/plugins.rst index 2530f78171..f8db01c3d8 100644 --- a/docs/features/plugins.rst +++ b/docs/features/plugins.rst @@ -4,6 +4,8 @@ Plugins ******* +.. versionadded:: 1.2.0 + Starting with OctoPrint 1.2.0, there's now a plugin system in place which allows to individually extend OctoPrint's functionality. @@ -28,7 +30,7 @@ Finding Plugins The official OctoPrint Plugin Repository can be found at `plugins.octoprint.org `_. -Some plugins may also be found in the list provided in `the OctoPrint wiki `_ +Some plugins may also be found in the list provided in `the OctoPrint wiki `_ and on the `OctoPrint organization Github page `_. .. _sec-features-plugins-installing: @@ -46,7 +48,7 @@ Manual Installation If you don't want or can't use the Plugin Manager, plugins may also be installed manually either by copying and unpacking them into one of the configured plugin folders (regularly those are ``/plugins`` and -``/plugins`` [#f1]_ or by installing them as regular python modules via ``pip`` [#f2]_. +``/plugins`` [#f1]_ or by installing them as regular Python modules via ``pip`` [#f2]_. For a plugin available on the Python Package Index (PyPi), the process is as simple as issuing a diff --git a/docs/features/safemode.rst b/docs/features/safemode.rst index c90d30d60d..625b5811e4 100644 --- a/docs/features/safemode.rst +++ b/docs/features/safemode.rst @@ -4,20 +4,24 @@ Safe mode ********* +.. versionadded:: 1.3.0 +.. versionchanged:: 1.3.13 + With the advent of support for plugins in OctoPrint, it quickly became apparent that some of the bugs -reported on OctoPrint's bug tracker were actually bugs with installed third party plugins instead of -OctoPrint itself. +reported on OctoPrint's bug tracker were actually bugs with installed third party plugins or language +packs instead of OctoPrint itself. To allow an easier identification of these cases, OctoPrint 1.3.0 introduced safe mode. Starting -OctoPrint in safe mode disables all plugins that are not bundled with OctoPrint, allowing to easier -identify most cases where a third party plugin is the culprit of an observed issue. +OctoPrint in safe mode disables all plugins (and starting with 1.3.13 also all language packs) that are +not bundled with OctoPrint, allowing to easier identify most cases where a third party plugin or language +pack is the culprit of an observed issue. -Additionally, OctoPrint allows uninstalling plugins in this mode, allowing recovery from cases where -a third party plugin causes the server to not start up or the web interface to not render or function -correctly anymore. +Additionally, OctoPrint allows uninstalling plugins and language packs in this mode, allowing recovery +from cases where a third party addition causes the server to not start up or the web interface to not +render or function correctly anymore. Whenever reporting an issue with OctoPrint, please always attempt to reproduce it in safe mode as well to -ensure it really is an issue in OctoPrint itself and now caused by one of your installed third party plugins. +ensure it really is an issue in OctoPrint itself and now caused by one of your installed third party additions. .. _sec-features-safemode-how: @@ -73,11 +77,17 @@ When OctoPrint is running in safe mode the following changes to its normal opera * OctoPrint will not enable any of the installed third party plugins. OctoPrint considers all plugins third party plugins that do not ship with OctoPrint's sources, so any plugins installed either via `pip` or - into OctoPrint's plugin folder at ``~/.octoprint/plugins`` (Linux), ``%APPDATA%/OctoPrint`` (Windows) and - ``~/Library/Application Support/OctoPrint`` (MacOS). + into OctoPrint's plugin folder at ``~/.octoprint/plugins`` (Linux), ``%APPDATA%/OctoPrint/plugins`` (Windows) and + ``~/Library/Application Support/OctoPrint/plugins`` (macOS). + * OctoPrint will not enable any of the installed third party language packs. OctoPrint considers all language packs + third party language packs that do not ship with OctoPrint's sources, so any language plugins installed + through the language pack manager within settings and/or stored in the language pack folder at + ``~/.octoprint/translations`` (Linux), ``%APPDATA%/OctoPrint/translations`` (Windows) or + ``~/Library/Application Support/OctoPrint/translations`` (macOS). * OctoPrint will still allow to uninstall third party plugins through the built-in Plugin Manager. * OctoPrint will still allow to disable (bundled) plugins that are still enabled. * OctoPrint will not allow to enable third party plugins. + * OctoPrint will still allow to manage language packs. * OctoPrint's web interface will display a notification to remind you that it is running in safe mode. diff --git a/docs/images/bundledplugins-action_command_notification-example.png b/docs/images/bundledplugins-action_command_notification-example.png new file mode 100644 index 0000000000..fc571db924 Binary files /dev/null and b/docs/images/bundledplugins-action_command_notification-example.png differ diff --git a/docs/images/bundledplugins-action_command_prompt-example.png b/docs/images/bundledplugins-action_command_prompt-example.png new file mode 100644 index 0000000000..cd8d719f18 Binary files /dev/null and b/docs/images/bundledplugins-action_command_prompt-example.png differ diff --git a/docs/images/bundledplugins-action_command_prompt-example2.png b/docs/images/bundledplugins-action_command_prompt-example2.png new file mode 100644 index 0000000000..bdc6d2e28c Binary files /dev/null and b/docs/images/bundledplugins-action_command_prompt-example2.png differ diff --git a/docs/images/bundledplugins-announcements-example1.png b/docs/images/bundledplugins-announcements-example1.png new file mode 100644 index 0000000000..153abeb29d Binary files /dev/null and b/docs/images/bundledplugins-announcements-example1.png differ diff --git a/docs/images/bundledplugins-announcements-example2.png b/docs/images/bundledplugins-announcements-example2.png new file mode 100644 index 0000000000..6cdda8521e Binary files /dev/null and b/docs/images/bundledplugins-announcements-example2.png differ diff --git a/docs/images/bundledplugins-announcements-settings.png b/docs/images/bundledplugins-announcements-settings.png new file mode 100644 index 0000000000..3e280c9dee Binary files /dev/null and b/docs/images/bundledplugins-announcements-settings.png differ diff --git a/docs/images/bundledplugins-appkeys-confirmation_prompt.png b/docs/images/bundledplugins-appkeys-confirmation_prompt.png new file mode 100644 index 0000000000..a1bbadec5e Binary files /dev/null and b/docs/images/bundledplugins-appkeys-confirmation_prompt.png differ diff --git a/docs/images/bundledplugins-appkeys-settings.png b/docs/images/bundledplugins-appkeys-settings.png new file mode 100644 index 0000000000..84c7cd24b3 Binary files /dev/null and b/docs/images/bundledplugins-appkeys-settings.png differ diff --git a/docs/images/bundledplugins-appkeys-user_settings.png b/docs/images/bundledplugins-appkeys-user_settings.png new file mode 100644 index 0000000000..96fd18ebc2 Binary files /dev/null and b/docs/images/bundledplugins-appkeys-user_settings.png differ diff --git a/docs/images/bundledplugins-backup-settings.png b/docs/images/bundledplugins-backup-settings.png new file mode 100644 index 0000000000..1d21c69e8e Binary files /dev/null and b/docs/images/bundledplugins-backup-settings.png differ diff --git a/docs/images/bundledplugins-gcodeviewer-example.png b/docs/images/bundledplugins-gcodeviewer-example.png new file mode 100644 index 0000000000..28f54fc73d Binary files /dev/null and b/docs/images/bundledplugins-gcodeviewer-example.png differ diff --git a/docs/images/bundledplugins-logging-settings.png b/docs/images/bundledplugins-logging-settings.png new file mode 100644 index 0000000000..c5b7612462 Binary files /dev/null and b/docs/images/bundledplugins-logging-settings.png differ diff --git a/docs/images/bundledplugins-loginui-dialog.png b/docs/images/bundledplugins-loginui-dialog.png new file mode 100644 index 0000000000..8508cb6d4e Binary files /dev/null and b/docs/images/bundledplugins-loginui-dialog.png differ diff --git a/docs/images/bundledplugins-printer_safety_check-example.png b/docs/images/bundledplugins-printer_safety_check-example.png new file mode 100644 index 0000000000..c6bac3a8e0 Binary files /dev/null and b/docs/images/bundledplugins-printer_safety_check-example.png differ diff --git a/docs/images/bundledplugins-softwareupdate-configuration.png b/docs/images/bundledplugins-softwareupdate-configuration.png new file mode 100644 index 0000000000..e61544dcd1 Binary files /dev/null and b/docs/images/bundledplugins-softwareupdate-configuration.png differ diff --git a/docs/images/bundledplugins-softwareupdate-plugin-configuration.png b/docs/images/bundledplugins-softwareupdate-plugin-configuration.png deleted file mode 100644 index ba13a99738..0000000000 Binary files a/docs/images/bundledplugins-softwareupdate-plugin-configuration.png and /dev/null differ diff --git a/docs/images/features-safemode-notification.png b/docs/images/features-safemode-notification.png index 4cdaa5f408..b9331c0b7b 100644 Binary files a/docs/images/features-safemode-notification.png and b/docs/images/features-safemode-notification.png differ diff --git a/docs/images/plugins_lifecycle.png b/docs/images/plugins_lifecycle.png deleted file mode 100644 index f0fe1e2d61..0000000000 Binary files a/docs/images/plugins_lifecycle.png and /dev/null differ diff --git a/docs/images/plugins_lifecycle.svg b/docs/images/plugins_lifecycle.svg new file mode 100644 index 0000000000..785e984e99 --- /dev/null +++ b/docs/images/plugins_lifecycle.svg @@ -0,0 +1,4 @@ + + + +
load
load
disabled
disabled
enabled
enabled
enable
enable
disable
disable
plugin_manager.plugins
plugin_manager.plugins
plugin.disable
deregister hooks
deregister implementation
plugin.implementation.on_plugin_disabled
plugin.disable...
plugin.enable
register hooks
register implementation
plugin.implementation.on_plugin_enabled

If/as soon as framework has started up:
inject dependencies into implementation
plugin.implementation.initialize

plugin.enable...
plugin.load
plugin.load
unload
unload
plugin.unload
plugin.unload
plugin_manager.enabled_plugins
plug...
plugin_manager.disabled_plugins
plug...
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 4af9306219..cdfa4b217d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,9 +6,24 @@ Welcome to OctoPrint's documentation! :alt: The OctoPrint Logo :align: right -This documentation is still in the process of being migrated from -`OctoPrint's wiki `_, so also take -a look there! +OctoPrint provides a snappy web interface for controlling consumer 3D printers. It is Free Software +and released under the `GNU Affero General Public License V3 `_. + +Its website can be found at `octoprint.org `_. + +The community forum is available at `community.octoprint.org `_. + +The FAQ can be accessed by following `faq.octoprint.org `_. + +The official plugin repository can be reached at `plugins.octoprint.org `_. + +OctoPrint's development wouldn't be possible without the `financial support by its community `_. +If you enjoy OctoPrint, please consider becoming a regular supporter! + +.. note:: + + This documentation currently focuses primarily on developers and less on end users. If you are interested in + helping to change this, please get in touch `on the forum `_! Contents ======== diff --git a/docs/jsclientlib/base.rst b/docs/jsclientlib/base.rst index 618299fd12..37416fd108 100644 --- a/docs/jsclientlib/base.rst +++ b/docs/jsclientlib/base.rst @@ -14,13 +14,13 @@ The client library instance's options. The following keys are currently supported: - ``apikey`` - The API Key to use for requests against the OctoPrint API. Should usually be - the anonymous UI API Key provided on the socket. - ``baseurl`` The base URL of the OctoPrint API + ``apikey`` + The API Key to use for requests against the OctoPrint API. If left unset, you'll + need to authenticate your session through a login. + ``locale`` The locale to set in ``X-Locale`` headers on API requests. Useful for API endpoints that might return localized content. @@ -417,7 +417,7 @@ Creates a custom exception class. ``name`` may be either a function in which case it will be used as constructor for the new exception class, or a string, in which case a constructor with proper - ``name``, ``message`` and ``stack`` attributes will be created. The class hierarchy will be propery + ``name``, ``message`` and ``stack`` attributes will be created. The class hierarchy will be properly setup to subclass ``Error``. **Example:** diff --git a/docs/jsclientlib/browser.rst b/docs/jsclientlib/browser.rst index d44ba08bf6..e71c26a603 100644 --- a/docs/jsclientlib/browser.rst +++ b/docs/jsclientlib/browser.rst @@ -29,6 +29,25 @@ Tries to perform a passive login into OctoPrint, using existing session data stored in the browser's cookies. + .. code-block:: javascript + + OctoPrint.browser.passiveLogin() + .done(function(response) { + // do something with the response + }) + + .. code-block:: javascript + + var client = new OctoPrintClient({ + baseurl: "https://example.com/", + apikey: "..." + }); + client.browser.passiveLogin() + .done(function(response) { + // do something with the response, e.g. authenticate with the socket + // via response.name and response.session + }) + :param object opts: Additional request options :returns Promise: A `jQuery Promise `_ for the request's response diff --git a/docs/jsclientlib/files.rst b/docs/jsclientlib/files.rst index 9c4d8233a9..5ecf5ea386 100644 --- a/docs/jsclientlib/files.rst +++ b/docs/jsclientlib/files.rst @@ -189,7 +189,7 @@ A boolean value, specifies whether to immediately start printing the file after the upload completes (true) or not (false, default) userdata - An optional object or a serialized JSON string of additional user supplised data to associate with + An optional object or a serialized JSON string of additional user supplied data to associate with the uploaded file. See :ref:`Upload file or create folder ` for more details on the file upload API and diff --git a/docs/jsclientlib/index.rst b/docs/jsclientlib/index.rst index 8fcc6ee181..5886f48055 100644 --- a/docs/jsclientlib/index.rst +++ b/docs/jsclientlib/index.rst @@ -32,7 +32,7 @@ correct URL prefix: --> -Regardless of which way you use to include the library, you'll also need to make sure you included JQuery and lodash, +Regardless of which way you use to include the library, you'll also need to make sure you included jQuery and lodash, because the library depends on those to be available (as ``$`` and ``_``). You can embed those like this: .. code-block:: html+jinja @@ -40,12 +40,18 @@ because the library depends on those to be available (as ``$`` and ``_``). You c +Moreover, if you need to use the socket module, you'll need to include sockJS. You can embed it like this: + +.. code-block:: html+jinja + + + Note that all components depend on the ``base`` component to be present, so if you are only including a select number of components, make sure to at the very least include that one to be able to utilize the client. When you import the client library as described above, a global variable ``OctoPrint`` will become available, which is a prepared instance of the ``OctoPrintClient`` class the library assembles from registered components. You can directly -used that singular ``OctoPrint`` instance if you only need to talk to one OctoPrint server: +use that singular ``OctoPrint`` instance if you only need to talk to one OctoPrint server: .. code-block:: javascript @@ -80,9 +86,9 @@ connection options (``baseurl`` and ``apikey``) directly in the constructor or s .. seealso:: - `OctoPrint-ForceLogin `_ - A plugin that disables anonymous access to the regular OctoPrint UI by implementing a custom UI. Utilizes the - client library's :ref:`browser component ` to login the user. + :ref:`Application Key Plugin ` + A bundled plugin that implements an authorization workflow for third party clients. It adds various additional + methods to the JS Client Library. .. toctree:: :maxdepth: 3 diff --git a/docs/jsclientlib/job.rst b/docs/jsclientlib/job.rst index eed5ac8081..834b67f70b 100644 --- a/docs/jsclientlib/job.rst +++ b/docs/jsclientlib/job.rst @@ -64,7 +64,7 @@ .. js:function:: OctoPrintClient.job.resume(opts) - Resumes the current job if it's currently pause, does nothing if it's running. + Resumes the current job if it's currently paused, does nothing if it's running. See :ref:`Issue a job command ` for details. diff --git a/docs/jsclientlib/languages.rst b/docs/jsclientlib/languages.rst index 5e187525fd..f13b6430be 100644 --- a/docs/jsclientlib/languages.rst +++ b/docs/jsclientlib/languages.rst @@ -5,7 +5,7 @@ .. note:: - All methods here require that the used API token or a the existing browser session + All methods here require that the used API token or the existing browser session has admin rights. .. js:function:: OctoPrintClient.languages.list(opts) diff --git a/docs/jsclientlib/logs.rst b/docs/jsclientlib/logs.rst index 41d9286619..fae86fb55d 100644 --- a/docs/jsclientlib/logs.rst +++ b/docs/jsclientlib/logs.rst @@ -3,37 +3,9 @@ :mod:`OctoPrintClient.logs` --------------------------- -.. note:: - - All methods here require that the used API token or a the existing browser session - has admin rights. - -.. js:function:: OctoPrintClient.logs.list(opts) - - Retrieves a list of log files. - - See :ref:`Retrieve a list of available log files ` for details. - - :param object opts: Additional options for the request - :returns Promise: A `jQuery Promise `_ for the request's response - -.. js:function:: OctoPrintClient.logs.delete(path, opts) - - Deletes the specified log ``path``. - - See :ref:`Delete a specific log file ` for details. - - :param string path: The path to the log file to delete - :param object opts: Additional options for the request - :returns Promise: A `jQuery Promise `_ for the request's response - -.. js:function:: OctoPrintClient.logs.download(path, opts) - - Downloads the specified log ``file``. - - See :js:func:`OctoPrint.download` for more details on the underlying library download mechanism. - - :param string path: The path to the log file to download - :param object opts: Additional options for the request - :returns Promise: A `jQuery Promise `_ for the request's response +Log file management (and logging configuration) was moved into a bundled plugin in OctoPrint 1.3.7. Refer to +:ref:`the Logging's plugins JS Client Library ` for the JS Client documentation. +The former module ``OctoPrintClient.logs`` and its methods are marked as deprecated but still work for now. New +client implementations should directly use the new module provided by the bundled plugin. Existing implementations +should adapt their used module as soon as possible. diff --git a/docs/jsclientlib/printer.rst b/docs/jsclientlib/printer.rst index b4e107d45d..e0c014babe 100644 --- a/docs/jsclientlib/printer.rst +++ b/docs/jsclientlib/printer.rst @@ -144,7 +144,7 @@ Sets the current flowrate multiplier. - ``factor`` is expected to be a integer value between 75 and 125 representing the new flowrate percentage. + ``factor`` is expected to be a integer value >0 representing the new flowrate percentage. See the ``flowrate`` command in :ref:`Issue a tool command ` for more details. @@ -174,7 +174,7 @@ Sets the given temperature on the printer's heated bed (if available). - ``target`` is expected to be a the target temperature as a float value. + ``target`` is expected to be the target temperature as a float value. **Example:** @@ -210,6 +210,64 @@ :param object opts: Additional options for the request :returns Promise: A `jQuery Promise `_ for the request's response +.. js:function:: OctoPrintClient.printer.getChamberState(data, opts) + + Retrieves the current printer chamber state/temperature information, and optionally also the temperature + history. + + The ``flags`` object can be used to specify the data to retrieve further via the following + properties: + + * ``history``: a boolean value to specify whether the temperature history should be included (``true``) + or not (``false``), defaults to it not being included + * ``limit``: an integer value to specify how many history entries to include + + See :ref:`Retrieve the current bed state ` for more details. + + :param object flags: Flags that further specify which data to retrieve, see above for details + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. js:function:: OctoPrintClient.printer.setChamberTargetTemperature(target, opts) + + Sets the given temperature on the printer's heated chamber (if available). + + ``target`` is expected to be the target temperature as a float value. + + **Example:** + + Set the chamber to 50°C. + + .. code-block:: javascript + + OctoPrint.printer.setChamberTargetTemperature(50.0); + + See the ``target`` command in :ref:`Issue a chamber command ` for more details. + + :param float target: The target to set + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + +.. js:function:: OctoPrintClient.printer.setChamberTemperatureOffset(offset, opts) + + Sets the given temperature offset for the printer's heated chamber (if available). + + ``offset`` is expected to be the temperature offset to set. + + **Example:** + + Set the offset for the chamber to -5°C. + + .. code-block:: javascript + + OctoPrint.printer.setChamberTemperatureOffset(-5); + + See the ``offset`` command in :ref:`Issue a chamber command ` for more details. + + :param object offsets: The offsets to set + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + .. js:function:: OctoPrintClient.printer.jog(amounts, opts) Jogs the specified axes by the specified ``amounts``. @@ -267,7 +325,7 @@ Sets the feedrate multiplier to use. - ``factor`` is expected to be a integer value between 0 and 200 representing the new feedrate percentage. + ``factor`` is expected to be a integer value >0 representing the new feedrate percentage. See the ``feedrate`` command in :ref:`Issue a print head command ` for more details. diff --git a/docs/jsclientlib/socket.rst b/docs/jsclientlib/socket.rst index 844b845a5a..fc4223546d 100644 --- a/docs/jsclientlib/socket.rst +++ b/docs/jsclientlib/socket.rst @@ -42,7 +42,7 @@ To register for all message types, provide ``*`` as the type to register for. ``handler`` is expected to be a function accepting one object parameter ``eventObj``, consisting - of the received message as property ``key`` and the received payload (if any) as property ``data``. + of the received message as property ``event`` and the received payload (if any) as property ``data``. .. code-block:: javascript @@ -64,12 +64,24 @@ Sends a message of type ``type`` with the provided ``payload`` to the server. - Note that at the time of writing, OctoPrint only supports the ``throttle`` message. See + Note that at the time of writing, OctoPrint only supports the ``throttle`` and ``auth`` messages. See also the :ref:`Push API documentation `. :param string type: Type of message to send :param object payload: Payload to send +.. js:function:: OctoPrintClient.socket.sendAuth(userId, session) + + Sends an ``auth`` message with the provided ``userId`` and ``session`` to the server. + + ``session`` is expected to be the ``session`` value retrieved + from any valid :ref:`OctoPrint.browser.login(userId,...) ` response. + + See also the :ref:`Push API documentation `. + + :param string userId: An existing OctoPrint username + :param string session: A valid session id for the provided username + .. js:function:: OctoPrintClient.socket.onRateTooLow(measured, minimum) Called by the socket client when the measured message round trip times have been lower than @@ -85,7 +97,7 @@ .. js:function:: OctoPrintClient.socket.onRateTooHigh(measured, maximum) Called by the socket client when the last measured round trip time was higher than the - current upper procesisng limit, indicating that the messages are now processed slower than + current upper processing limit, indicating that the messages are now processed slower than the current rate requires and a slower rate might be necessary. Can be overwritten with custom handler methods. The default implementation will call @@ -102,6 +114,35 @@ Instructs the server to decrease the message rate by 500ms. +.. _sec-jsclient-socket-authsample: + +Sample to setup an authed socket +================================ + +If you have a username and a password: + +.. code-block:: javascript + + OctoPrint.socket.connect(); + OctoPrint.browser.login("myusername", "mypassword", true) + .done(function(response) { + OctoPrint.socket.sendAuth("myusername", response.session); + }); + +If you have an API key: + +.. code-block:: javascript + + var client = new OctoPrintClient({ + baseurl: "http://example.com/", + apikey: "abcdef" + }); + client.socket.connect(); + client.browser.passiveLogin() + .done(function(response) { + client.socket.sendAuth(response.name, response.session); + }); + .. _sec-jsclient-socket-throttling: Communication Throttling diff --git a/docs/jsclientlib/system.rst b/docs/jsclientlib/system.rst index 7099771412..a82b89f294 100644 --- a/docs/jsclientlib/system.rst +++ b/docs/jsclientlib/system.rst @@ -5,7 +5,7 @@ .. note:: - All methods here require that the used API token or a the existing browser session + All methods here require that the used API token or the existing browser session has admin rights. .. js:function:: OctoPrintClient.system.getCommands(opts) @@ -35,4 +35,4 @@ .. seealso:: :ref:`System API ` - Documentation of the underlying system API + Documentation of the underlying system API. diff --git a/docs/jsclientlib/timelapse.rst b/docs/jsclientlib/timelapse.rst index a16b8ec9ad..351483ceb3 100644 --- a/docs/jsclientlib/timelapse.rst +++ b/docs/jsclientlib/timelapse.rst @@ -15,7 +15,7 @@ .. js:function:: OctoPrintClient.timelapse.list(opts) - Get the lists of rendered and unrendered timelapses. The returned promis + Get the lists of rendered and unrendered timelapses. The returned promise will be resolved with an object containing the properties ``rendered`` which will have the list of rendered timelapses, and ``unrendered`` which will have the list of unrendered timelapses. diff --git a/docs/jsclientlib/users.rst b/docs/jsclientlib/users.rst index a07ac7cf9c..dd11adb193 100644 --- a/docs/jsclientlib/users.rst +++ b/docs/jsclientlib/users.rst @@ -5,7 +5,7 @@ .. note:: - Most methods here require that the used API token or a the existing browser session + Most methods here require that the used API token or the existing browser session has admin rights *or* corresponds to the user to be modified. Some methods definitely require admin rights. diff --git a/docs/jsclientlib/util.rst b/docs/jsclientlib/util.rst index 4569d1d794..a5454b64da 100644 --- a/docs/jsclientlib/util.rst +++ b/docs/jsclientlib/util.rst @@ -5,7 +5,7 @@ .. note:: - All methods here require that the used API token or a the existing browser session + All methods here require that the used API token or the existing browser session has admin rights. .. js:function:: OctoPrintClient.util.test(command, parameters, opts) @@ -187,7 +187,7 @@ .. code-block:: javascript - OctoPrint.util.testUrl("127.0.0.1", 1234, {"protocol": "udp"}) + OctoPrint.util.testServer("127.0.0.1", 1234, {"protocol": "udp"}) .done(function(response) { if (response.result) { // check passed @@ -203,7 +203,31 @@ :param object opts: Additional options for the request :returns Promise: A `jQuery Promise `_ for the request's response +.. js:function:: OctoPrintClient.util.testResolution(name, additional, opts) + + Test if a host name can be resolved. + + **Example** + + Test if ``octoprint.org`` can be resolved. + + .. code-block:: javascript + + OctoPrint.util.testResolution("octoprint.org") + .done(function(response) { + if (response.result) { + // check passed + } else { + // check failed + } + }); + + :param string name: Host name to test + :param object additional: Additional parameters for the test command + :param object opts: Additional options for the request + :returns Promise: A `jQuery Promise `_ for the request's response + .. seealso:: :ref:`Util API ` - Documentation of the underlying util API + Documentation of the underlying util API. diff --git a/docs/jsclientlib/wizard.rst b/docs/jsclientlib/wizard.rst index 12a0cad251..90de044d07 100644 --- a/docs/jsclientlib/wizard.rst +++ b/docs/jsclientlib/wizard.rst @@ -5,7 +5,7 @@ .. note:: - All methods here require that the used API token or a the existing browser session + All methods here require that the used API token or the existing browser session has admin rights. .. js:function:: OctoPrintClient.wizard.get(opts) diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 4eea5d5bf4..0000000000 --- a/docs/make.bat +++ /dev/null @@ -1,242 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\OctoPrint.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\OctoPrint.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %BUILDDIR%/.. - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %BUILDDIR%/.. - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - -:end diff --git a/docs/modules/printer.rst b/docs/modules/printer.rst index ce752f431b..5fcd54ae45 100644 --- a/docs/modules/printer.rst +++ b/docs/modules/printer.rst @@ -4,3 +4,10 @@ octoprint.printer ----------------- .. automodule:: octoprint.printer + +.. _sec-modules-printer-profile: + +octoprint.printer.profile +------------------------- + +.. automodule:: octoprint.printer.profile diff --git a/docs/modules/slicing.rst b/docs/modules/slicing.rst index bf27d4865b..12509284de 100644 --- a/docs/modules/slicing.rst +++ b/docs/modules/slicing.rst @@ -10,4 +10,4 @@ octoprint.slicing octoprint.slicing.exceptions ---------------------------- -.. automodule:: octoprint.slicing.exceptions \ No newline at end of file +.. automodule:: octoprint.slicing.exceptions diff --git a/docs/modules/users.rst b/docs/modules/users.rst index f147a46a33..2cf3163f5f 100644 --- a/docs/modules/users.rst +++ b/docs/modules/users.rst @@ -1,9 +1,9 @@ .. _sec-modules-users: -octoprint.users ---------------- +octoprint.access.users +---------------------- -.. automodule:: octoprint.users +.. automodule:: octoprint.access.users :members: diff --git a/docs/modules/util.rst b/docs/modules/util.rst index 32a61b728d..134b9a1bff 100644 --- a/docs/modules/util.rst +++ b/docs/modules/util.rst @@ -6,4 +6,18 @@ octoprint.util .. automodule:: octoprint.util :members: +.. _sec-modules-util-commandline: +octoprint.util.commandline +-------------------------- + +.. automodule:: octoprint.util.commandline + :members: + +.. _sec-modules-util-platform: + +octoprint.util.platform +----------------------- + +.. automodule:: octoprint.util.platform + :members: diff --git a/docs/plugins/concepts.rst b/docs/plugins/concepts.rst index ceaf3acaa7..add1acf03c 100644 --- a/docs/plugins/concepts.rst +++ b/docs/plugins/concepts.rst @@ -23,9 +23,9 @@ Lifecycle There are three sources of installed plugins that OctoPrint will check during start up: - * it's own ``octoprint/plugins`` folder (this is where the bundled plugins reside), + * its own ``octoprint/plugins`` folder (this is where the bundled plugins reside), * the ``plugins`` folder in its configuration directory (e.g. ``~/.octoprint/plugins`` on Linux), - * any python packages registered for the entry point ``octoprint.plugin``. + * any Python packages registered for the entry point ``octoprint.plugin``. Each plugin that OctoPrint finds it will first load, then enable. On enabling a plugin, OctoPrint will register its declared :ref:`hook handlers ` and :ref:`helpers `, apply @@ -36,9 +36,9 @@ any :ref:`settings overlays =2.7,<3`` if not set and thus Python 2 but no + Python 3 compatibility. + + If your plugin is compatible to Python 3, you should set this to ``>=2.7,<4``, otherwise your plugin will not load + on OctoPrint instances installed under Python 3. + + .. code-block:: python + + __plugin_pythoncompat__ = ">=2.7,<4" + + .. _sec-plugins-controlproperties-plugin_implementation: ``__plugin_implementation__`` diff --git a/docs/plugins/distributing.rst b/docs/plugins/distributing.rst index f01bbf79bf..35325d38fb 100644 --- a/docs/plugins/distributing.rst +++ b/docs/plugins/distributing.rst @@ -60,3 +60,22 @@ that in the `Plugin Repository's help pages `_ when creating your plugin, you can find a prepared registration entry ``.md`` file in the ``extras`` folder of your plugin. + +Version management after the official plugin repository release +--------------------------------------------------------------- + +Once your plugin is available in the official plugin repository, you probably want to create and distribute new versions. +For "beta" users you can use the manual file distribution method, or a more elegant release channels (see below). +After you finalized a new plugin version, don't forget to actually update the version in the ``setup.py``, +and `submit a new release on github `_. + +After you published the new release, you can verify it on your installed octoprint, +with force checking the updates under the advanced options (in the software updates menu in the settings). +The new versions will appear to the plugin users in the next 24 hours (it depends on their cache refreshes). + +The `Software Update Plugin `_ has options to define multiple release channels, +and you can let the users decide if they want to test your pre-releases or not. +This can be achieved with defining ``stable_branch`` and ``prerelease_branches`` in the ``get_update_information`` function, +and creating github releases to the newly configured branches too. +For more information you can check the `Software Update Plugin documentation `_ +or read a more step-by-step writeup `here `_. diff --git a/docs/plugins/gettingstarted.rst b/docs/plugins/gettingstarted.rst index 8392bb0d3e..3ac2939d54 100644 --- a/docs/plugins/gettingstarted.rst +++ b/docs/plugins/gettingstarted.rst @@ -14,7 +14,7 @@ First of all let use make sure that you have OctoPrint checked out and set up fo development environment:: $ cd ~/devel - $ git clone https://github.com/foosel/OctoPrint + $ git clone https://github.com/OctoPrint/OctoPrint [...] $ cd OctoPrint $ virtualenv venv @@ -41,6 +41,9 @@ development environment:: [...] + Setting up a local development environment will most likely be less painful than developing directly + on the Pi. So do yourself the favor and do that instead where possible. + .. important:: This tutorial assumes you are running OctoPrint 1.3.0 and up. Please make sure your version of @@ -54,12 +57,13 @@ We'll start at the most basic form a plugin can take - just a few simple lines o .. code-block:: python :linenos: - # coding=utf-8 - from __future__ import absolute_import + # -*- coding: utf-8 -*- + from __future__ import absolute_import, unicode_literals __plugin_name__ = "Hello World" __plugin_version__ = "1.0.0" __plugin_description__ = "A quick \"Hello World\" example plugin for OctoPrint" + __plugin_pythoncompat__ = ">=2.7,<4" Saving this as ``helloworld.py`` in ``~/.octoprint/plugins`` yields you something resembling these log entries upon server startup:: @@ -75,7 +79,10 @@ Saving this as ``helloworld.py`` in ``~/.octoprint/plugins`` yields you somethin 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 -``__plugin_description__`` and stored it in an internal data structure, but we'll just ignore this for now. +``__plugin_description__`` and stored it in an internal data structure, but we'll just ignore this for now. Additionally +there is ``__plugin_pythoncompat__`` which tells OctoPrint here that your plugin can be run under any Python versions +between 2.7 and 4. That is necessary so that your plugin will be loadable in OctoPrint instances running under either +Python 2 or Python 3, and compatibility to both should be your goal. .. _sec-plugins-gettingstarted-sayinghello: @@ -86,11 +93,11 @@ Apart from being discovered by OctoPrint, our plugin does nothing yet. We want t "Hello World!" to the log upon server startup. Modify our ``helloworld.py`` like this: .. code-block:: python - :emphasize-lines: 4-8,13 + :emphasize-lines: 4-8,14 :linenos: - # coding=utf-8 - from __future__ import absolute_import + # -*- coding: utf-8 -*- + from __future__ import absolute_import, unicode_literals import octoprint.plugin @@ -101,6 +108,7 @@ Apart from being discovered by OctoPrint, our plugin does nothing yet. We want t __plugin_name__ = "Hello World" __plugin_version__ = "1.0.0" __plugin_description__ = "A quick \"Hello World\" example plugin for OctoPrint" + __plugin_pythoncompat__ = ">=2.7,<4" __plugin_implementation__ = HelloWorldPlugin() and restart OctoPrint. You now get this output in the log:: @@ -134,8 +142,8 @@ as a simple python file following the naming convention ``.py ``~/.octoprint/plugins`` folder. You already know how that works. But let's say you have more than just a simple plugin that can be done in one file. Distributing multiple files and getting your users to install them in the right way so that OctoPrint will be able to actually find and load them is certainly not impossible, but we want to do it in the -best way possible, meaning we want to make our plugin a fully installable python module that your users will be able to -install directly via `OctoPrint's built-in Plugin Manager `_ +best way possible, meaning we want to make our plugin a fully installable Python module that your users will be able to +install directly via `OctoPrint's built-in Plugin Manager `_ or alternatively manually utilizing Python's standard package manager ``pip`` directly. So let's begin. We'll use the `cookiecutter `_ template for OctoPrint plugins @@ -282,15 +290,15 @@ of information now defined twice: plugin_version = "1.0.0" plugin_description = "A quick \"Hello World\" example plugin for OctoPrint" -The nice thing about our plugin now being a proper python package is that OctoPrint can and will access the metadata defined +The nice thing about our plugin now being a proper Python package is that OctoPrint can and will access the metadata defined within ``setup.py``! So, we don't really need to define all this data twice. Remove ``__plugin_name__``, ``__plugin_version__`` -and ``__plugin_description__`` from ``__init__.py``: +and ``__plugin_description__`` from ``__init__.py``, but leave ``__plugin_implementation__`` and ``__plugin_pythoncompat__``: .. code-block:: python :linenos: - # coding=utf-8 - from __future__ import absolute_import + # -*- coding: utf-8 -*- + from __future__ import absolute_import, unicode_literals import octoprint.plugin @@ -298,6 +306,7 @@ and ``__plugin_description__`` from ``__init__.py``: def on_after_startup(self): self._logger.info("Hello World!") + __plugin_pythoncompat__ = ">=2.7,<4" __plugin_implementation__ = HelloWorldPlugin() and restart OctoPrint:: @@ -314,8 +323,8 @@ Our "Hello World" Plugin still gets detected fine, but it's now listed under the :emphasize-lines: 10 :linenos: - # coding=utf-8 - from __future__ import absolute_import + # -*- coding: utf-8 -*- + from __future__ import absolute_import, unicode_literals import octoprint.plugin @@ -324,6 +333,7 @@ Our "Hello World" Plugin still gets detected fine, but it's now listed under the self._logger.info("Hello World!") __plugin_name__ = "Hello World" + __plugin_pythoncompat__ = ">=2.7,<4" __plugin_implementation__ = HelloWorldPlugin() @@ -362,8 +372,8 @@ add the :class:`TemplatePlugin` to our ``HelloWorldPlugin`` class: :emphasize-lines: 7 :linenos: - # coding=utf-8 - from __future__ import absolute_import + # -*- coding: utf-8 -*- + from __future__ import absolute_import, unicode_literals import octoprint.plugin @@ -373,6 +383,7 @@ add the :class:`TemplatePlugin` to our ``HelloWorldPlugin`` class: self._logger.info("Hello World!") __plugin_name__ = "Hello World" + __plugin_pythoncompat__ = ">=2.7,<4" __plugin_implementation__ = HelloWorldPlugin() Next, we'll create a sub folder ``templates`` underneath our ``octoprint_helloworld`` folder, and within that a file @@ -410,8 +421,8 @@ Now look at that! Settings Galore: How to make parts of your plugin user adjustable ----------------------------------------------------------------- -Remember that Wikipedia link we added to our little link in the navigation bar? It links to the english Wikipedia. But -what if we want to allow our users to adjust that according to their wishes, e.g. to link to the german language node +Remember that Wikipedia link we added to our little link in the navigation bar? It links to the English Wikipedia. But +what if we want to allow our users to adjust that according to their wishes, e.g. to link to the German language node about "Hello World" programs instead? To allow your users to customized the behaviour of your plugin you'll need to implement the :class:`~octoprint.plugin.SettingsPlugin` @@ -428,8 +439,8 @@ Let's take a look at how all that would look in our plugin's ``__init__.py``: :emphasize-lines: 8, 10, 12-13 :linenos: - # coding=utf-8 - from __future__ import absolute_import + # -*- coding: utf-8 -*- + from __future__ import absolute_import, unicode_literals import octoprint.plugin @@ -443,6 +454,7 @@ Let's take a look at how all that would look in our plugin's ``__init__.py``: return dict(url="https://en.wikipedia.org/wiki/Hello_world") __plugin_name__ = "Hello World" + __plugin_pythoncompat__ = ">=2.7,<4" __plugin_implementation__ = HelloWorldPlugin() Restart OctoPrint. You should see something like this:: @@ -462,8 +474,8 @@ Adjust your plugin's ``__init__.py`` like this: :emphasize-lines: 15-16 :linenos: - # coding=utf-8 - from __future__ import absolute_import + # -*- coding: utf-8 -*- + from __future__ import absolute_import, unicode_literals import octoprint.plugin @@ -480,6 +492,7 @@ Adjust your plugin's ``__init__.py`` like this: return dict(url=self._settings.get(["url"])) __plugin_name__ = "Hello World" + __plugin_pythoncompat__ = ">=2.7,<4" __plugin_implementation__ = HelloWorldPlugin() Also adjust your plugin's ``templates/helloworld_navbar.jinja2`` like this: @@ -509,7 +522,7 @@ section doesn't yet exist in the file, create it): # [...] Restart OctoPrint. Not only should the URL displayed in the log file have changed, but also the link should now (after -a proper shift-reload) point to the german Wikipedia node about "Hello World" programs:: +a proper shift-reload) point to the German Wikipedia node about "Hello World" programs:: 2015-01-30 11:47:18,634 - octoprint.plugins.helloworld - INFO - Hello World! (more: https://de.wikipedia.org/wiki/Hallo-Welt-Programm) @@ -569,8 +582,8 @@ again since we don't use that anymore: :emphasize-lines: 15-19 :linenos: - # coding=utf-8 - from __future__ import absolute_import + # -*- coding: utf-8 -*- + from __future__ import absolute_import, unicode_literals import octoprint.plugin @@ -590,6 +603,7 @@ again since we don't use that anymore: ] __plugin_name__ = "Hello World" + __plugin_pythoncompat__ = ">=2.7,<4" __plugin_implementation__ = HelloWorldPlugin() Restart OctoPrint and shift-reload your browser. Your link in the navigation bar should still point to the URL we @@ -666,8 +680,8 @@ like so: :emphasize-lines: 9,22-25 :linenos: - # coding=utf-8 - from __future__ import absolute_import + # -*- coding: utf-8 -*- + from __future__ import absolute_import, unicode_literals import octoprint.plugin @@ -693,6 +707,7 @@ like so: ) __plugin_name__ = "Hello World" + __plugin_pythoncompat__ = ">=2.7,<4" __plugin_implementation__ = HelloWorldPlugin() Note how we did not add another entry to the return value of :func:`~octoprint.plugin.TemplatePlugin.get_template_configs`. @@ -846,8 +861,8 @@ a reference to our CSS file: :emphasize-lines: 26 :linenos: - # coding=utf-8 - from __future__ import absolute_import + # -*- coding: utf-8 -*- + from __future__ import absolute_import, unicode_literals import octoprint.plugin @@ -875,6 +890,7 @@ a reference to our CSS file: ) __plugin_name__ = "Hello World" + __plugin_pythoncompat__ = ">=2.7,<4" __plugin_implementation__ = HelloWorldPlugin() OctoPrint by default bundles all CSS, JavaScript and LESS files to reduce the amount of requests necessary to fully @@ -931,8 +947,8 @@ Then adjust our returned assets to include our LESS file as well: :emphasize-lines: 27 :linenos: - # coding=utf-8 - from __future__ import absolute_import + # -*- coding: utf-8 -*- + from __future__ import absolute_import, unicode_literals import octoprint.plugin @@ -961,6 +977,7 @@ Then adjust our returned assets to include our LESS file as well: ) __plugin_name__ = "Hello World" + __plugin_pythoncompat__ = ">=2.7,<4" __plugin_implementation__ = HelloWorldPlugin() @@ -1074,7 +1091,7 @@ you haven't seen yet, :ref:`take a look at the available plugin mixins `_ might be a good example to learn from. For how to -add support for a slicer, OctoPrint's own bundled `CuraEngine plugin `_ +add support for a slicer, the `CuraEngine Legacy plugin `_ might give some hints. For extending OctoPrint's interface, the `NavbarTemp plugin `_ might show what's possible with a few lines of code already. Finally, just take a look at the `official Plugin Repository `_ if you are looking for examples. diff --git a/docs/plugins/helpers.rst b/docs/plugins/helpers.rst index 17cd182897..73b2a7935f 100644 --- a/docs/plugins/helpers.rst +++ b/docs/plugins/helpers.rst @@ -3,12 +3,12 @@ Helpers ======= -Helpers are methods that plugin can exposed to other plugins in order to make common functionality available on the +Helpers are methods that plugins can expose to other plugins in order to make common functionality available on the system. They are registered with the OctoPrint plugin system through the use of the control property ``__plugin_helpers__``. An example for providing some helper functions to the system can be found in the -`Discovery Plugin `_, -which provides it's SSDP browsing and Zeroconf browsing and publishing functions as helper methods. +`Discovery Plugin `_, +which provides its SSDP browsing and Zeroconf browsing and publishing functions as helper methods. .. code-block:: python :linenos: diff --git a/docs/plugins/hooks.rst b/docs/plugins/hooks.rst index 75c255da6a..1f65e91af4 100644 --- a/docs/plugins/hooks.rst +++ b/docs/plugins/hooks.rst @@ -28,6 +28,7 @@ or as ``postfix`` (after the existing lines). .. code-block:: python :linenos: + from past import basestring self._gcode_hooks = self._pluginManager.get_hooks("octoprint.comm.protocol.scripts") # ... @@ -35,7 +36,7 @@ or as ``postfix`` (after the existing lines). for hook in self._gcodescript_hooks: try: retval = self._gcodescript_hooks[hook](self, "gcode", scriptName) - except: + except Exception: self._logger.exception("Error while processing gcodescript hook %s" % hook) else: if retval is None: @@ -44,10 +45,8 @@ or as ``postfix`` (after the existing lines). continue def to_list(data): - if isinstance(data, str): - data = map(str.strip, data.split("\n")) - elif isinstance(data, unicode): - data = map(unicode.strip, data.split("\n")) + if isinstance(data, basestring): + data = map(x.strip() for x in data.split("\n")) if isinstance(data, (list, tuple)): return list(data) @@ -66,7 +65,7 @@ the general type of script for which to look for additions ("gcode") and the scr return a 2-tuple of prefix and postfix if has something for either of those, otherwise ``None``. OctoPrint will then take care to add prefix and suffix as necessary after a small round of preprocessing. -Plugins can easily add their own hooks too. For example, the `Software Update Plugin `_ +Plugins can easily add their own hooks too. For example, the `Software Update Plugin `_ declares a custom hook "octoprint.plugin.softwareupdate.check_config" which other plugins can add handlers for in order to register themselves with the Software Update Plugin by returning their own update check configuration. @@ -201,30 +200,97 @@ Available plugin hooks .. contents:: :local: -.. _sec-plugins-hook-accesscontrol-appkey: +.. _sec-plugins-hook-permissions: -octoprint.accesscontrol.appkey +octoprint.access.permissions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: additional_permissions_hook(*args, **kwargs) + + .. versionadded:: 1.4.0 + + Return a list of additional permissions to register in the system on behalf of the plugin. Use this + to add granular permissions to your plugin which can be configured for users and user groups in the general + access control settings of OctoPrint. + + Additional permissions must be modelled as ``dict``s with at least a ``key`` and ``name`` field. Possible + fields are as follows: + + * ``key``: A key for the permission to be used for referring to it from source code. This will turned uppercase + and prefixed with ``PLUGIN__`` before being made available on ``octoprint.access.permissions.Permissions``, + e.g. ``my_permission`` on the plugin with identifier ``example`` turns into ``PLUGIN_EXAMPLE_MY_PERMISSION`` and + can be accessed as ``octoprint.access.permissions.Permissions.PLUGIN_EXAMPLE_MY_PERMISSION`` on the server and + ``permissions.PLUGIN_EXAMPLE_MY_PERMISSION`` on the ``AccessViewModel`` on the client. Must only contain a-z, A-Z, 0-9 and _. + * ``name``: A human readable name for the permission. + * ``description``: A human readable description of the permission. + * ``permissions``: A list of permissions this permission includes, by key. + * ``roles``: A list of roles this permission includes. Roles are simple strings you define. Usually one role will + suffice. + * ``dangerous``: Whether this permission should be considered dangerous (``True``) or not (``False``) + * ``default_groups``: A list of standard groups this permission should be apply to by default. Standard groups + are ``admins``, ``users``, ``readonly`` and ``guests`` + + The following example is based on some actual code included in the bundled Application Keys plugin and defines + one additional permission called ``ADMIN`` with a role ``admin`` which is marked as dangerous (since it gives + access to the management to other user's application keys) and by default will only be given to the standard admin + group: + + .. code-block:: python + + def get_additional_permissions(*args, **kwargs): + return [ + dict(key="ADMIN", + name="Admin access", + description=gettext("Allows administrating all application keys"), + roles=["admin"], + dangerous=True, + default_groups=[ADMIN_GROUP]) + ] + + __plugin_hooks__ = { + "octoprint.access.permissions": get_additional_permissions + } + + Once registered it can be referenced under the key ``PLUGIN_APPKEYS_ADMIN``. + + :return: A list of additional permissions to register in the system. + :rtype: A list of dicts. + +.. _sec-plugins-hook-users-factory: + +octoprint.access.users.factory ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. py:function:: acl_appkey_hook(*args, **kwargs) +.. py:function:: user_manager_factory_hook(components, settings, *args, **kwargs) - By handling this hook plugins may register additional :ref:`App session key providers ` - within the system. + .. versionadded:: 1.4.0 - Overrides this to return your additional app information to be used for validating app session keys. You'll - need to return a list of 3-tuples of the format (id, version, public key). + Return a :class:`~octoprint.access.users.UserManager` instance to use as global user manager object. This will + be called only once during initial server startup. - The ``id`` should be the (unique) identifier of the app. Using a domain prefix might make sense here, e.g. - ``org.octoprint.example.MyApp``. + The provided ``components`` is a dictionary containing the already initialized system components: - ``version`` should be a string specifying the version of the app for which the public key is valid. You can - provide the string ``any`` here, in which case the provided public key will be valid for all versions of the - app for which no specific public key is defined. + * ``plugin_manager``: The :class:`~octoprint.plugin.core.PluginManager` + * ``printer_profile_manager``: The :class:`~octoprint.printer.profile.PrinterProfileManager` + * ``event_bus``: The :class:`~octoprint.events.EventManager` + * ``analysis_queue``: The :class:`~octoprint.filemanager.analysis.AnalysisQueue` + * ``slicing_manager``: The :class:`~octoprint.slicing.SlicingManager` + * ``file_manager``: The :class:`~octoprint.filemanager.FileManager` + * ``plugin_lifecycle_manager``: The :class:`~octoprint.server.LifecycleManager` + * ``preemptive_cache``: The :class:`~octoprint.server.util.flask.PreemptiveCache` - Finally, the public key is expected to be provided as a PKCS1 string without newlines. + If the factory returns anything but ``None``, it will be assigned to the global ``userManager`` instance. + + If none of the registered factories return a user manager instance, the class referenced by the ``config.yaml`` + entry ``accessControl.userManager`` will be initialized if possible, otherwise a stock + :class:`~octoprint.access.users.FilebasedUserManager` will be instantiated, linked to the default user storage + file ``~/.octoprint/users.yaml``. + + :param dict components: System components to use for user manager instance initialization + :param SettingsManager settings: The global settings manager instance to fetch configuration values from if necessary + :return: The ``userManager`` instance to use globally. + :rtype: UserManager subclass or None - :return: A list of 3-tuples as described above - :rtype: list .. _sec-plugins-hook-accesscontrol-keyvalidator: @@ -233,11 +299,13 @@ octoprint.accesscontrol.keyvalidator .. py:function:: acl_keyvalidator_hook(apikey, *args, **kwargs) + .. versionadded:: 1.3.6 + Via this hook plugins may validate their own customized API keys to be used to access OctoPrint's API. ``apikey`` will be the API key as read from the request headers. - Hook handlers are expected to return a :class:`~octoprint.users.User` instance here that will then be considered that + Hook handlers are expected to return a :class:`~octoprint.access.users.User` instance here that will then be considered that user making the request. By returning ``None`` or nothing at all, hook handlers signal that they do not handle the provided key. @@ -255,7 +323,7 @@ octoprint.accesscontrol.keyvalidator :param str apikey: The API key to validate :return: The user in whose name the request will be processed further - :rtype: :class:`~octoprint.users.User` + :rtype: :class:`~octoprint.access.users.User` .. _sec-plugins-hook-cli-commands: @@ -264,6 +332,8 @@ octoprint.cli.commands .. py:function:: cli_commands_hook(cli_group, pass_octoprint_ctx, *args, **kwargs) + .. versionadded:: 1.3.0 + By providing a handler for this hook plugins may register commands on OctoPrint's command line interface (CLI). Handlers are expected to return a list of callables annotated as `Click commands `_ to register with the @@ -380,20 +450,93 @@ octoprint.cli.commands OctoPrint's CLI. :rtype: list +.. _sec-plugins-hook-comm-protocol-firmware-info: + +octoprint.comm.protocol.firmware.info +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: firmware_info_hook(comm_instance, firmware_name, firmware_data, *args, **kwargs) + + .. versionadded:: 1.3.9 + + Be notified of firmware information received from the printer following an ``M115``. + + Hook handlers may use this to react/adjust behaviour based on reported firmware data. OctoPrint parses the received + report line and provides the parsed ``firmware_name`` and additional ``firmware_data`` contained therein. A + response line ``FIRMWARE_NAME:Some Firmware Name FIRMWARE_VERSION:1.2.3 PROTOCOL_VERSION:1.0`` for example will + be turned into a ``dict`` looking like this: + + .. code-block:: python + + dict(FIRMWARE_NAME="Some Firmware Name", + FIRMWARE_VERSION="1.2.3", + PROTOCOL_VERSION="1.0") + + ``firmware_name`` will be ``Some Firmware Name`` in this case. + + .. warning:: + + Make sure to not perform any computationally expensive or otherwise long running actions within these handlers as + you will effectively block the receive loop, causing the communication with the printer to stall. + + This includes I/O of any kind. + + :param object comm_instance: The :class:`~octoprint.util.comm.MachineCom` instance which triggered the hook. + :param str firmware_name: The parsed name of the firmware + :param dict firmware_data: All data contained in the ``M115`` report + +.. _sec-plugins-hook-comm-protocol-firmware-capabilities: + +octoprint.comm.protocol.firmware.capabilities +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: firmware_capability_hook(comm_instance, capability, enabled, already_defined, *args, **kwargs) + + .. versionadded:: 1.3.9 + + Be notified of capability report entries received from the printer. + + Hook handlers may use this to react to custom firmware capabilities. OctoPrint parses the received capability + line and provides the parsed ``capability`` and whether it's ``enabled`` to the handler. Additionally all already + parsed capabilities will also be provided. + + Note that hook handlers will be called once per received capability line. + + .. warning:: + + Make sure to not perform any computationally expensive or otherwise long running actions within these handlers as + you will effectively block the receive loop, causing the communication with the printer to stall. + + This includes I/O of any kind. + + :param object comm_instance: The :class:`~octoprint.util.comm.MachineCom` instance which triggered the hook. + :param str capability: The name of the parsed capability + :param bool enabled: Whether the capability is reported as enabled or disabled + :param dict already_defined: Already defined capabilities (capability name mapped to enabled flag) + .. _sec-plugins-hook-comm-protocol-action: octoprint.comm.protocol.action ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. py:function:: protocol_action_hook(comm_instance, line, action, *args, **kwargs) +.. py:function:: protocol_action_hook(comm_instance, line, action, name='', params='', *args, **kwargs) + + .. versionadded:: 1.2.0 React to a :ref:`action command ` received from the printer. - Hook handlers may use this to react to react to custom firmware messages. OctoPrint parses the received action + Hook handlers may use this to react to custom firmware messages. OctoPrint parses the received action command ``line`` and provides the parsed ``action`` (so anything after ``// action:``) to the hook handler. No returned value is expected. + .. warning:: + + Make sure to not perform any computationally expensive or otherwise long running actions within your handlers as + you will effectively block the receive loop, causing the communication with the printer to stall. + + This includes I/O of any kind. + **Example:** Logs if the ``custom`` action (``// action:custom``) is received from the printer's firmware. @@ -405,22 +548,76 @@ octoprint.comm.protocol.action :param object comm_instance: The :class:`~octoprint.util.comm.MachineCom` instance which triggered the hook. :param str line: The complete line as received from the printer, format ``// action:`` - :param str action: The parsed out action command, so for a ``line`` like ``// action:some_command`` this will be + :param str action: The parsed out action command incl. parameters, so for a ``line`` like ``// action:some_command key value`` this will be + ``some_command key value`` + :param str name: The action command name, for a ``line`` like ``// action:some_command key value`` this will be ``some_command`` + :param str params: The action command's parameter, for a ``line`` like ``// action:some_command key value`` this will + be ``key value`` + +.. _sec-plugins-hook-comm-protocol-atcommand-phase: + +octoprint.comm.protocol.atcommand. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This describes actually two hooks: + + * ``octoprint.comm.protocol.atcommand.queuing`` + * ``octoprint.comm.protocol.atcommand.sending`` + +.. py:function:: protocol_atcommandphase_hook(comm_instance, phase, command, parameters, tags=None, *args, **kwargs) + + .. versionadded:: 1.3.7 + + Trigger on :ref:`@ commands ` as they progress through the ``queuing`` and ``sending`` + phases of the comm layer. See :ref:`the gcode phase hook ` for a + detailed description of each of these phases. + + Hook handlers may use this to react to arbitrary :ref:`@ commands ` included in GCODE files + streamed to the printer or sent as part of GCODE scripts, through the API or plugins. + + Please note that these hooks do not allow to rewrite, suppress or expand @ commands, they are merely callbacks to + trigger the *actual execution* of whatever functionality lies behind a given @ command, similar to + :ref:`the action command hook `. + + .. warning:: + + Make sure to not perform any computationally expensive or otherwise long running actions within your handlers as + you will effectively block the send/receive loops, causing the communication with the printer to stall. + + This includes I/O of any kind. + + **Example** + + Pause the print on ``@wait`` (this mirrors the implementation of the built-in ``@pause`` command, just with a + different name). + + .. onlineinclude:: https://raw.githubusercontent.com/OctoPrint/Plugin-Examples/master/custom_atcommand.py + :linenos: + :tab-width: 4 + :caption: `custom_action_command.py `__ + + :param object comm_instance: The :class:`~octoprint.util.comm.MachineCom` instance which triggered the hook. + :param str phase: The current phase in the command progression, either ``queuing`` or ``sending``. Will always + match the ```` of the hook. + :param str cmd: The @ command without the leading @ + :param str parameters: Any parameters provided to the @ command. If none were provided this will be an empty string. .. _sec-plugins-hook-comm-protocol-gcode-phase: octoprint.comm.protocol.gcode. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -This describes actually four hooks: +This actually describes four hooks: * ``octoprint.comm.protocol.gcode.queuing`` * ``octoprint.comm.protocol.gcode.queued`` * ``octoprint.comm.protocol.gcode.sending`` * ``octoprint.comm.protocol.gcode.sent`` -.. py:function:: protocol_gcodephase_hook(comm_instance, phase, cmd, cmd_type, gcode, subcode=None, *args, **kwargs) +.. py:function:: protocol_gcodephase_hook(comm_instance, phase, cmd, cmd_type, gcode, subcode=None, tags=None, *args, **kwargs) + + .. versionadded:: 1.2.0 Pre- and postprocess commands as they progress through the various phases of being sent to the printer. The phases are the following: @@ -443,7 +640,39 @@ This describes actually four hooks: the communication layer or before they are actually sent over the serial port, or to react to the queuing or sending of commands after the fact. The hook handler will be called with the processing ``phase``, the ``cmd`` to be sent to the printer as well as the ``cmd_type`` parameter used for enqueuing (OctoPrint will make sure that the send queue - will never contain more than one line with the same ``cmd_type``) and the detected gcode command (if it is one). + will never contain more than one line with the same ``cmd_type``) and the detected ``gcode`` command (if it is one) + as well as its ``subcode`` (if it has one). OctoPrint will also provide any ``tags`` attached to the command throughout + its lifecycle. + + Tags are arbitrary strings that can be attached to a command as it moves through the various phases and can be used to e.g. + distinguish between commands that originated in a printed file (``source:file``) vs. a configured GCODE script + (``source:script``) vs. an API call (``source:api``) vs. a plugin (``source:plugin`` or ``source:rewrite`` and + ``plugin:``). If during development you want to get an idea of the various possible tags, set + the logger ``octoprint.util.comm.command_phases`` to ``DEBUG``, connect to a printer (real or virtual) and take a + look at your ``octoprint.log`` during serial traffic: + + .. code-block:: none + + 2018-02-16 18:20:31,213 - octoprint.util.comm.command_phases - DEBUG - phase: queuing | command: T0 | gcode: T | tags: [ api:printer.command, source:api, trigger:printer.commands ] + 2018-02-16 18:20:31,216 - octoprint.util.comm.command_phases - DEBUG - phase: queued | command: M117 Before T! | gcode: M117 | tags: [ api:printer.command, phase:queuing, plugin:multi_gcode_test, source:api, source:rewrite, trigger:printer.commands ] + 2018-02-16 18:20:31,217 - octoprint.util.comm.command_phases - DEBUG - phase: sending | command: M117 Before T! | gcode: M117 | tags: [ api:printer.command, phase:queuing, plugin:multi_gcode_test, source:api, source:rewrite, trigger:printer.commands ] + 2018-02-16 18:20:31,217 - octoprint.util.comm.command_phases - DEBUG - phase: queued | command: T0 | gcode: T | tags: [ api:printer.command, source:api, trigger:printer.commands ] + 2018-02-16 18:20:31,219 - octoprint.util.comm.command_phases - DEBUG - phase: queued | command: M117 After T! | gcode: M117 | tags: [ api:printer.command, phase:queuing, plugin:multi_gcode_test, source:api, source:rewrite, trigger:printer.commands ] + 2018-02-16 18:20:31,220 - octoprint.util.comm.command_phases - DEBUG - phase: sent | command: M117 Before T! | gcode: M117 | tags: [ api:printer.command, phase:queuing, plugin:multi_gcode_test, source:api, source:rewrite, trigger:printer.commands ] + 2018-02-16 18:20:31,230 - tornado.access - INFO - 204 POST /api/printer/command (127.0.0.1) 23.00ms + 2018-02-16 18:20:31,232 - tornado.access - INFO - 200 POST /api/printer/command (127.0.0.1) 25.00ms + 2018-02-16 18:20:31,232 - octoprint.util.comm.command_phases - DEBUG - phase: sending | command: T0 | gcode: T | tags: [ api:printer.command, source:api, trigger:printer.commands ] + 2018-02-16 18:20:31,234 - octoprint.util.comm.command_phases - DEBUG - phase: sent | command: T0 | gcode: T | tags: [ api:printer.command, source:api, trigger:printer.commands ] + 2018-02-16 18:20:31,242 - octoprint.util.comm.command_phases - DEBUG - phase: sending | command: M117 After T! | gcode: M117 | tags: [ api:printer.command, phase:queuing, plugin:multi_gcode_test, source:api, source:rewrite, trigger:printer.commands ] + 2018-02-16 18:20:31,243 - octoprint.util.comm.command_phases - DEBUG - phase: sent | command: M117 After T! | gcode: M117 | tags: [ api:printer.command, phase:queuing, plugin:multi_gcode_test, source:api, source:rewrite, trigger:printer.commands ] + 2018-02-16 18:20:38,552 - octoprint.util.comm.command_phases - DEBUG - phase: queuing | command: G91 | gcode: G91 | tags: [ api:printer.printhead, source:api, trigger:printer.commands, trigger:printer.jog ] + 2018-02-16 18:20:38,552 - octoprint.util.comm.command_phases - DEBUG - phase: queued | command: G91 | gcode: G91 | tags: [ api:printer.printhead, source:api, trigger:printer.commands, trigger:printer.jog ] + 2018-02-16 18:20:38,553 - octoprint.util.comm.command_phases - DEBUG - phase: sending | command: G91 | gcode: G91 | tags: [ api:printer.printhead, source:api, trigger:printer.commands, trigger:printer.jog ] + 2018-02-16 18:20:38,553 - octoprint.util.comm.command_phases - DEBUG - phase: queuing | command: G1 X10 F6000 | gcode: G1 | tags: [ api:printer.printhead, source:api, trigger:printer.commands, trigger:printer.jog ] + 2018-02-16 18:20:38,555 - octoprint.util.comm.command_phases - DEBUG - phase: queued | command: G1 X10 F6000 | gcode: G1 | tags: [ api:printer.printhead, source:api, trigger:printer.commands, trigger:printer.jog ] + 2018-02-16 18:20:38,556 - octoprint.util.comm.command_phases - DEBUG - phase: sent | command: G91 | gcode: G91 | tags: [ api:printer.printhead, source:api, trigger:printer.commands, trigger:printer.jog ] + 2018-02-16 18:20:38,556 - octoprint.util.comm.command_phases - DEBUG - phase: queuing | command: G90 | gcode: G90 | tags: [ api:printer.printhead, source:api, trigger:printer.commands, trigger:printer.jog ] + 2018-02-16 18:20:38,558 - octoprint.util.comm.command_phases - DEBUG - phase: queued | command: G90 | gcode: G90 | tags: [ api:printer.printhead, source:api, trigger:printer.commands, trigger:printer.jog ] Defining a ``cmd_type`` other than None will make sure OctoPrint takes care of only having one command of that type in its sending queue. Predefined types are ``temperature_poll`` for temperature polling via ``M105`` and @@ -482,6 +711,8 @@ This describes actually four hooks: should use this option. * A 2-tuple consisting of a rewritten version of the ``cmd`` and the ``cmd_type``, e.g. ``return "M105", "temperature_poll"``. Handlers which wish to rewrite both the command and the command type should use this option. + * A 3-tuple consisting of a rewritten version of the ``cmd``, the ``cmd_type`` and any additional ``tags`` you might + want to attach to the lifecycle of the command in a set, e.g. ``return "M105", "temperature_poll", {"my_custom_tag"}`` * **"queuing" phase only**: A list of any of the above to allow for expanding one command into many. The following example shows how any queued command could be turned into a sequence of a temperature query, line number reset, display of the ``gcode`` on the printer's display and finally the actual command (this example @@ -489,7 +720,7 @@ This describes actually four hooks: .. code-block:: python - def rewrite_foo(self, comm_instance, phase, cmd, cmd_type, gcode, *args, **kwargs): + def rewrite_foo(self, comm_instance, phase, cmd, cmd_type, gcode, subcode=None, tags=None *args, **kwargs): if gcode or not cmd.startswith("@foo"): return @@ -504,6 +735,13 @@ This describes actually four hooks: Note: Only one command of a given ``cmd_type`` (other than None) may be queued at a time. Trying to rewrite the ``cmd_type`` to one already in the queue will give an error. + .. warning:: + + Make sure to not perform any computationally expensive or otherwise long running actions within these handlers as + you will effectively block the send loop, causing the communication with the printer to stall. + + This includes I/O of any kind. + **Example** The following hook handler replaces all ``M107`` ("Fan Off", deprecated) with an ``M106 S0`` ("Fan On" with speed @@ -524,6 +762,7 @@ This describes actually four hooks: :param str gcode: Parsed GCODE command, e.g. ``G0`` or ``M110``, may also be None if no known command could be parsed :param str subcode: Parsed subcode of the GCODE command, e.g. ``1`` for ``M80.1``. Will be None if no subcode was provided or no command could be parsed. + :param tags: Tags attached to the command :return: None, 1-tuple, 2-tuple or string, see the description above for details. .. _sec-plugins-hook-comm-protocol-gcode-received: @@ -533,11 +772,20 @@ octoprint.comm.protocol.gcode.received .. py:function:: gcode_received_hook(comm_instance, line, *args, **kwargs) + .. versionadded:: 1.3.0 + Get the returned lines sent by the printer. Handlers should return the received line or in any case, the modified - version of it. If the the handler returns None, processing will be aborted and the communication layer will get an + version of it. If the handler returns None, processing will be aborted and the communication layer will get an empty string as the received line. Note that Python functions will also automatically return ``None`` if an empty ``return`` statement is used or just nothing is returned explicitly from the handler. + .. warning:: + + Make sure to not perform any computationally expensive or otherwise long running actions within these handlers as + you will effectively block the receive loop, causing the communication with the printer to stall. + + This includes I/O of any kind. + **Example:** Looks for the response of an ``M115``, which contains information about the ``MACHINE_TYPE``, among other things. @@ -552,6 +800,45 @@ octoprint.comm.protocol.gcode.received :return: The received line or in any case, a modified version of it. :rtype: str +.. _sec-plugins-hook-comm-protocol-gcode-error: + +octoprint.comm.protocol.gcode.error +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: gcode_error_hook(comm_instance, error_message, *args, **kwargs) + + .. versionadded:: 1.3.7 + + Get the messages of any errors messages sent by the printer, with the leading ``Error:`` or ``!!`` already + stripped. Handlers should return True if they handled that error internally and it should not be processed by + the system further. Normal processing of these kinds of errors - depending on the configuration of error + handling - involves canceling the ongoing print and possibly also disconnecting. + + Plugins might utilize this hook to handle errors generated by the printer that are recoverable in one way or + the other and should not trigger the normal handling that assumes the worst. + + .. warning:: + + Make sure to not perform any computationally expensive or otherwise long running actions within these handlers as + you will effectively block the receive loop, causing the communication with the printer to stall. + + This includes I/O of any kind. + + **Example:** + + Looks for error messages containing "fan error" or "bed missing" (ignoring case) and marks them as handled by the + plugin. + + .. onlineinclude:: https://raw.githubusercontent.com/OctoPrint/Plugin-Examples/master/comm_error_handler_test.py + :linenos: + :tab-width: 4 + :caption: `comm_error_handler_test.py `_ + + :param MachineCom comm_instance: The :class:`~octoprint.util.comm.MachineCom` instance which triggered the hook. + :param str error_message: The error message received from the printer. + :return: True if the error was handled in the plugin and should not be processed further, False (or None) otherwise. + :rtype: bool + .. _sec-plugins-hook-comm-protocol-scripts: octoprint.comm.protocol.scripts @@ -559,19 +846,23 @@ octoprint.comm.protocol.scripts .. py:function:: protocol_scripts_hook(comm_instance, script_type, script_name, *args, **kwargs) - Return a prefix to prepend and a postfix to append to the script ``script_name`` of type ``type``. Handlers should + .. versionadded:: 1.2.0 + .. versionchanged:: 1.3.7 + + Return a prefix to prepend, postfix to append, and optionally a dictionary of variables to provide to the script ``script_name`` of type ``type``. Handlers should make sure to only proceed with returning additional scripts if the ``script_type`` and ``script_name`` match handled scripts. If not, None should be returned directly. - If the hook handler has something to add to the specified script, it may return a 2-tuple, with the first entry - defining the prefix (what to *prepend* to the script in question) and the last entry defining the postfix (what to - *append* to the script in question). Both prefix and postfix can be None to signify that nothing should be prepended + If the hook handler has something to add to the specified script, it may return a 2-tuple, a 3-tuple or a 4-tuple with the first entry + defining the prefix (what to *prepend* to the script in question), the second entry defining the postfix (what to + *append* to the script in question), and finally if desired a dictionary of variables to be made available to the script on third and additional tags to set on the + commands on fourth position. Both prefix and postfix can be None to signify that nothing should be prepended respectively appended. - The returned entries may be either iterables of script lines or a string including newlines of the script lines (which + The returned prefix and postfix entries may be either iterables of script lines or a string including newlines of the script lines (which will be split by the caller if necessary). - **Example:** + **Example 1:** Appends an ``M117 OctoPrint connected`` to the configured ``afterPrinterConnected`` GCODE script. @@ -580,10 +871,19 @@ octoprint.comm.protocol.scripts :tab-width: 4 :caption: `message_on_connect.py `_ + **Example 2:** + + Provides the variable ``myvariable`` to the configured ``beforePrintStarted`` GCODE script. + + .. onlineinclude:: https://raw.githubusercontent.com/OctoPrint/Plugin-Examples/master/gcode_script_variables.py + :linenos: + :tab-width: 4 + :caption: `gcode_script_variables.py `_ + :param MachineCom comm_instance: The :class:`~octoprint.util.comm.MachineCom` instance which triggered the hook. :param str script_type: The type of the script for which the hook was called, currently only "gcode" is supported here. :param str script_name: The name of the script for which the hook was called. - :return: A 2-tuple in the form ``(prefix, postfix)`` or None + :return: A 2-tuple in the form ``(prefix, postfix)``, 3-tuple in the form ``(prefix, postfix, variables)``, or None :rtype: tuple or None .. _sec-plugins-hook-comm-protocol-temperatures-received: @@ -593,6 +893,8 @@ octoprint.comm.protocol.temperatures.received .. py:function:: protocol_temperatures_received_hook(comm_instance, parsed_temperatures, *args, **kwargs) + .. versionadded:: 1.3.6 + Get the parsed temperatures returned by the printer, allowing handlers to modify them prior to handing them off to the system. Handlers are expected to either return ``parsed_temperatures`` as-is or a modified copy thereof. @@ -603,6 +905,13 @@ octoprint.comm.protocol.temperatures.received additional sanity checking to be applied and invalid values to be filtered out. If a handler returns an empty dictionary or ``None``, no further processing will take place. + .. warning:: + + Make sure to not perform any computationally expensive or otherwise long running actions within these handlers as + you will effectively block the receive loop, causing the communication with the printer to stall. + + This includes I/O of any kind. + **Example** The following example shows how to filter out actual temperatures that are outside a sane range of 1°C to 300°C. @@ -612,6 +921,27 @@ octoprint.comm.protocol.temperatures.received :tab-width: 4 :caption: `sanitize_temperatures.py `_ +.. _sec-plugins-hook-comm-transport-serial-additonal-port-names: + +octoprint.comm.transport.serial.additional_port_names +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: additional_port_names_hook(candidates, *args, **kwargs) + + .. versionadded:: 1.4.1 + + Return additional port names (not glob patterns!) to use as a serial connection to the printer. Expected to be + ``list`` of ``string``. + + Useful in combination with :ref:`octoprint.comm.transport.serial.factory ` + to implement custom serial-like ports through plugins. + + For an example of use see the bundled ``virtual_printer`` plugin. + + :param list candidates: The port names already found on the system available for connection. + :return: Additional port names to offer up for connection. + :rtype: list + .. _sec-plugins-hook-comm-transport-serial-factory: octoprint.comm.transport.serial.factory @@ -619,6 +949,8 @@ octoprint.comm.transport.serial.factory .. py:function:: serial_factory_hook(comm_instance, port, baudrate, read_timeout, *args, **kwargs) + .. versionadded:: 1.2.0 + Return a serial object to use as serial connection to the printer. If a handler cannot create a serial object for the specified ``port`` (and ``baudrate``), it should just return ``None``. @@ -629,21 +961,21 @@ octoprint.comm.transport.serial.factory closely if directly utilizing :class:`~octoprint.util.comm.MachineCom` functionality. A valid serial instance is expected to provide the following methods, analogue to PySerial's - `serial.Serial `_: + :py:class:`serial.Serial`: readline(size=None, eol='\n') - Reads a line from the serial connection, compare `serial.Filelike.readline `_. + Reads a line from the serial connection, compare :py:meth:`serial.Serial.readline`. write(data) - Writes data to the serial connection, compare `serial.Filelike.write `_. + Writes data to the serial connection, compare :py:meth:`serial.Serial.write`. close() - Closes the serial connection, compare `serial.Serial.close `_. + Closes the serial connection, compare :py:meth:`serial.Serial.close`. Additionally setting the following attributes need to be supported if baudrate detection is supposed to work: baudrate - An integer describing the baudrate to use for the serial connection, compare `serial.Serial.baudrate `_. + An integer describing the baudrate to use for the serial connection, compare :py:attr:`serial.Serial.baudrate`. timeout - An integer describing the read timeout on the serial connection, compare `serial.Serial.timeout `_. + An integer describing the read timeout on the serial connection, compare :py:attr:`serial.Serial.timeout`. **Example:** @@ -688,6 +1020,76 @@ octoprint.comm.transport.serial.factory :rtype: A serial instance implementing implementing the methods ``readline(...)``, ``write(...)``, ``close()`` and optionally ``baudrate`` and ``timeout`` attributes as described above. +.. _sec-plugins-hook-events-register_custom_events: + +octoprint.events.register_custom_events +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: register_custom_events_hook(*args, **kwargs) + + .. versionadded:: 1.3.11 + + Return a list of custom :ref:`events ` to register in the system for your plugin. + + Should return a list of strings which represent the custom events. Their name on the `octoprint.events.Events` object + will be the returned value transformed into upper case ``CAMEL_CASE`` and prefixed with ``PLUGIN_``. Their + value will be prefixed with ``plugin__``. + + Example: + + Consider the following hook part of a plugin with the identifier ``myplugin``. It will register two custom events + in the system, ``octoprint.events.Events.PLUGIN_MYPLUGIN_MY_CUSTOM_EVENT`` with value ``plugin_myplugin_my_custom_event`` + and ``octoprint.events.Events.PLUGIN_MYPLUGIN_MY_OTHER_CUSTOM_EVENT`` with value ``plugin_myplugin_my_other_custom_event``. + + .. code-block:: python + :linenos: + + def register_custom_events(*args, **kwargs): + return ["my_custom_event", "my_other_custom_event"] + + :return: A list of custom events to register + :rtype: list + +.. _sec-plugins-hook-filemanager-analysis-factory: + +octoprint.filemanager.analysis.factory +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: analysis_queue_factory_hook(*args, **kwargs) + + .. versionadded:: 1.3.9 + + Return additional (or replacement) analysis queue factories used for analysing uploaded files. + + Should return a dictionary to merge with the existing dictionary of factories, mapping from extension tree leaf + to analysis queue factory. Analysis queue factories are expected to be :class:`~octoprint.filemanager.analysis.AbstractAnalysisQueue` + subclasses or factory methods taking one argument (the finish callback to be used by the queue implementation + to signal that an analysis has been finished to the system). See the source of :class:`~octoprint.filemanager.analysis.GcodeAnalysisQueue` + for an example. + + By default, only one analysis queue factory is registered in the system, for file type ``gcode``: :class:`~octoprint.filemanager.analysis.GcodeAnalysisQueue`. + This can be replaced by plugins using this hook, allowing other approaches to file analysis. + + This is useful for plugins wishing to provide (alternative) methods of metadata analysis for printable files. + + **Example:** + + The following handler would replace the existing analysis queue for ``gcode`` files with a custom implementation: + + .. code-block:: python + :linenos: + + from octoprint.filemanager.analysis import AbstractAnalysisQueue + + class MyCustomGcodeAnalysisQueue(AbstractAnalysisQueue): + # ... custom implementation here ... + + def custom_gcode_analysis_queue(*args, **kwargs): + return dict(gcode=MyCustomGcodeAnalysisQueue) + + :return: A dictionary of analysis queue factories, mapped by their targeted file type. + :rtype: dict + .. _sec-plugins-hook-filemanager-extensiontree: octoprint.filemanager.extension_tree @@ -695,6 +1097,8 @@ octoprint.filemanager.extension_tree .. py:function:: file_extension_hook(*args, **kwargs) + .. versionadded:: 1.2.0 + Return additional entries for the tree of accepted file extensions for uploading/handling by the file manager. Should return a dictionary to merge with the existing extension tree, adding additional extension groups to @@ -731,6 +1135,8 @@ octoprint.filemanager.preprocessor .. py:function:: file_preprocessor_hook(path, file_object, links=None, printer_profile=None, allow_overwrite=False, *args, **kwargs) + .. versionadded:: 1.2.0 + Replace the ``file_object`` used for saving added files to storage by calling :func:`~octoprint.filemanager.util.AbstractFileWrapper.save`. ``path`` will be the future path of the file on the storage. The file's name is accessible via @@ -738,7 +1144,7 @@ octoprint.filemanager.preprocessor ``file_object`` will be a subclass of :class:`~octoprint.filemanager.util.AbstractFileWrapper`. Handlers may access the raw data of the file via :func:`~octoprint.filemanager.util.AbstractFileWrapper.stream`, e.g. - to wrap it further. Handlers which do not wish to handle the `file_object` + to wrap it further. Handlers which do not wish to handle the `file_object` should just return it untouched. **Example** @@ -758,6 +1164,62 @@ octoprint.filemanager.preprocessor :return: The `file_object` as passed in or None, or a replaced version to use instead for further processing. :rtype: AbstractFileWrapper or None +.. _sec-plugins-hook-plugin-backup-excludes: + +octoprint.plugin.backup.additional_excludes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 1.5.0 + +See :ref:`here `. + +.. _sec-plugins-hook-plugin-pluginmanager-reconnect: + +octoprint.plugin.pluginmanager.reconnect_hooks +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 1.4.0 + +See :ref:`here `. + +.. _sec-plugins-hook-plugin-softwareupdate-check_config: + +octoprint.plugin.softwareupdate.check_config +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 1.2.0 + +See :ref:`here `. + +.. _sec-plugins-hooks-plugin-printer-additional_state_data: + +octoprint.printer.additional_state_data +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: additional_state_data_hook(initial=False, *args, **kwargs) + + .. versionadded:: 1.5.0 + + Use this to inject additional data into the data structure returned from the printer backend to the frontend + on the push socket or other registered :class:`octoprint.printer.PrinterCallback`. Anything you return here + will be located beneath ``plugins.`` in the resulting initial and current data push structure. + + The ``initial`` parameter will be ``True`` if this the additional update sent to the callback. Your handler should + return a ``dict``, or ``None`` if nothing should be included. + + .. warning:: + + Make sure to not perform any computationally expensive or otherwise long running actions within these handlers as + you could stall the whole state monitor and thus updates being pushed to the frontend. + + This includes I/O of any kind. + + Cache your data! + + :param boolean initial: True if this is the initial update, False otherwise + :return: Additional data to include + :rtype: dict + .. _sec-plugins-hook-printer-factory: octoprint.printer.factory @@ -765,6 +1227,8 @@ octoprint.printer.factory .. py:function:: printer_factory_hook(components, *args, **kwargs) + .. versionadded:: 1.3.0 + Return a :class:`~octoprint.printer.PrinterInstance` instance to use as global printer object. This will be called only once during initial server startup. @@ -776,20 +1240,225 @@ octoprint.printer.factory * ``analysis_queue``: The :class:`~octoprint.filemanager.analysis.AnalysisQueue` * ``slicing_manager``: The :class:`~octoprint.slicing.SlicingManager` * ``file_manager``: The :class:`~octoprint.filemanager.FileManager` - * ``app_session_manager``: The :class:`~octoprint.server.util.flask.AppSessionManager` * ``plugin_lifecycle_manager``: The :class:`~octoprint.server.LifecycleManager` - * ``user_manager``: The :class:`~octoprint.users.UserManager` + * ``user_manager``: The :class:`~octoprint.access.users.UserManager` * ``preemptive_cache``: The :class:`~octoprint.server.util.flask.PreemptiveCache` If the factory returns anything but ``None``, it will be assigned to the global ``printer`` instance. - If no of the registered factories return a printer instance, the default :class:`~octoprint.printer.standard.Printer` + If none of the registered factories return a printer instance, the default :class:`~octoprint.printer.standard.Printer` class will be instantiated. :param dict components: System components to use for printer instance initialization :return: The ``printer`` instance to use globally. :rtype: PrinterInterface subclass or None +.. _sec-plugins-hook-printer-handle_connect: + +octoprint.printer.handle_connect +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: handle_connect(*args, **kwargs): + + .. versionadded:: 1.6.0 + + Allows plugins to perform actions upon connecting to a printer. By returning ``True``, + plugins may also prevent further processing of the connect command. This hook is of + special interest if your plugin needs a connect from going through under certain + circumstances or if you need to do something before a connection to the printer is + established (e.g. switching on power to the printer). + + :param kwargs: All connection parameters supplied to the ``connect`` call. Currently + this also includes ``port``, ``baudrate`` and ``profile``. + :return: ``True`` if OctoPrint should not proceed with the connect + :rtype: boolean or None + +.. _sec-plugins-hook-printer-estimation-factory: + +octoprint.printer.estimation.factory +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: print_time_estimator_factory(*args, **kwargs) + + .. versionadded:: 1.3.9 + + Return a :class:`~octoprint.printer.estimation.PrintTimeEstimator` subclass (or factory) to use for print time + estimation. This will be called on each start of a print or streaming job with a single parameter ``job_type`` + denoting the type of job that was just started: ``local`` meaning a print of a local file through the serial connection, + ``sdcard`` a print of a file stored on the printer's SD card, ``stream`` the streaming of a local file to the + printer's SD card. + + This is useful for plugins wishing to provide alternative methods of live print time estimation. + + If none of the registered factories return a ``PrintTimeEstimator`` subclass, the default :class:`~octoprint.printer.estimation.PrintTimeEstimator` + will be used. + + **Example:** + + The following example would replace the stock print time estimator with (a nonsensical) one that always estimates + two hours of print time left: + + .. code-block:: python + + from octoprint.printer.estimation import PrintTimeEstimator + + class CustomPrintTimeEstimator(PrintTimeEstimator): + def __init__(self, job_type): + pass + + def estimate(self, progress, printTime, cleanedPrintTime, statisticalTotalPrintTime, statisticalTotalPrintTimeType): + # always reports 2h as printTimeLeft + return 2 * 60 * 60, "estimate" + + def create_estimator_factory(*args, **kwargs): + return CustomPrintTimeEstimator + + __plugin_hooks__ = { + "octoprint.printer.estimation.factory": create_estimator_factory + } + + + :return: The :class:`~octoprint.printer.estimation.PrintTimeEstimator` class to use, or a factory method + :rtype: class or function + +.. _sec-plugins-hook-octoprint-printer-sdcardupload: + +octoprint.printer.sdcardupload +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: sd_card_upload_hook(printer, filename, path, start_callback, success_callback, failure_callback, *args, **kwargs) + + .. versionadded:: 1.3.11 + + Via this hook plugins can change the way files are being uploaded to the sd card of the printer. + + Implementations **must** call the provided ``start_callback`` on start of the file transfer and either the ``success_callback`` + or ``failure_callback`` on the end of the file transfer, depending on whether it was successful or not. + + The ``start_callback`` has the following signature: + + .. code-block:: python + + def start_callback(local_filename, remote_filename): + # ... + + ``local_filename`` must be the name of the file on the ``local`` storage, ``remote_filename`` the name of the file + to be created on the ``sdcard`` storage. + + ``success_callback`` and ``failure_callback`` both have the following signature: + + .. code-block:: python + + def success_or_failure_callback(local_filename, remote_filename, elapsed): + # ... + + ``local_filename`` must be the name of the file on the ``local`` storage, ``remote_filename`` the name of the file + to be created on the ``sdcard`` storage. ``elapsed`` is the elapsed time in seconds. + + If the hook is going to handle the upload, it must return the (future) remote filename of the file on the ``sdcard`` + storage. If it returns ``None`` (or an otherwise falsy value), OctoPrint will interpret this as the hook not going to + handle the file upload, in which case the next hook or - if no other hook is registered - the default implementation + will be called. + + **Example** + + The following example creates a dummy SD card uploader that does nothing but sleep for ten seconds when a file + is supposed to be uploaded. Note that the long running process of sleeping for ten seconds is extracted into its + own thread, which is important in order to not block the main application! + + .. code-block:: python + + import threading + import logging + import time + + def nop_upload_to_sd(printer, filename, path, sd_upload_started, sd_upload_succeeded, sd_upload_failed, *args, **kwargs): + logger = logging.getLogger(__name__) + + remote_name = printer._get_free_remote_name(filename) + logger.info("Starting dummy SDCard upload from {} to {}".format(filename, remote_name)) + + sd_upload_started(filename, remote_name) + + def process(): + logger.info("Sleeping 10s...") + time.sleep(10) + logger.info("And done!") + sd_upload_succeeded(filename, remote_name, 10) + + thread = threading.Thread(target=process) + thread.daemon = True + thread.start() + + return remote_name + + __plugin_name__ = "No-op SDCard Upload Test" + __plugin_hooks__ = { + "octoprint.printer.sdcardupload": nop_upload_to_sd + } + + .. versionadded:: 1.3.11 + + :param object printer: the :py:class:`~octoprint.printer.PrinterInterface` instance the hook was called from + :param str filename: filename on the ``local`` storage + :param str path: path of the file in the local file system + :param function sd_upload_started: callback for when the upload started + :param function sd_upload_success: callback for successful finish of upload + :param function sd_upload_failure: callback for failure of upload + :return: the name of the file on the ``sdcard`` storage or ``None`` + :rtype: string or ``None`` + +.. _sec-plugins-hook-server-http-after_request: + +octoprint.server.api.after_request +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: after_request_handlers_hook(*args, **kwargs) + + .. versionadded:: 1.3.10 + + Allows adding additional after-request-handlers to API endpoints defined by OctoPrint itself and installed plugins. + + Your plugin might need this to further restrict access to API methods. + + .. important:: + + Implementing this hook will make your plugin require a restart of OctoPrint for enabling/disabling it fully. + +.. _sec-plugins-hook-server-http-before_request: + +octoprint.server.api.before_request +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: before_request_handlers_hook(*args, **kwargs) + + .. versionadded:: 1.3.10 + + Allows adding additional before-request-handlers to API endpoints defined by OctoPrint itself and installed plugins. + + Your plugin might need this to further restrict access to API methods. + + .. important:: + + Implementing this hook will make your plugin require a restart of OctoPrint for enabling/disabling it fully. + +.. _sec-plugins-hook-server-http-access_validator: + +octoprint.server.http.access_validator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: access_validator_hook(request, *args, **kwargs) + + .. versionadded:: 1.3.10 + + Allows adding additional access validators to the default tornado routers. + + Your plugin might need to this to restrict acccess to downloads and webcam snapshots further. + + .. important:: + + Implementing this hook will make your plugin require a restart of OctoPrint for enabling/disabling it fully. + .. _sec-plugins-hook-server-http-bodysize: octoprint.server.http.bodysize @@ -797,6 +1466,8 @@ octoprint.server.http.bodysize .. py:function:: server_bodysize_hook(current_max_body_sizes, *args, **kwargs) + .. versionadded:: 1.2.0 + Allows extending the list of custom maximum body sizes on the web server per path and HTTP method with custom entries from plugins. @@ -839,6 +1510,8 @@ octoprint.server.http.routes .. py:function:: server_route_hook(server_routes, *args, **kwargs) + .. versionadded:: 1.2.0 + Allows extending the list of routes registered on the web server. This is interesting for plugins which want to provide their own download URLs which will then be delivered statically @@ -864,6 +1537,14 @@ octoprint.server.http.routes view of the blueprint will thus not be reachable since processing of the request will directly be handed over to your defined handler class. + .. important:: + + If you want your route to support CORS if it's enabled in OctoPrint, your `RequestHandler `_ + needs to implement the :class:`~octoprint.server.util.tornado.CorsSupportMixin` for this to work. Note that all of + :class:`~octoprint.server.util.tornado.LargeResponseHandler`, :class:`~octoprint.server.util.tornado.UrlProxyHandler`, + :class:`~octoprint.server.util.tornado.StaticDataHandler` and :class:`~octoprint.server.util.tornado.DeprecatedEndpointHandler` + already implement this mixin. + .. important:: Implementing this hook will make your plugin require a restart of OctoPrint for enabling/disabling it fully. @@ -885,12 +1566,197 @@ octoprint.server.http.routes that allows delivery of the requested resource as attachment and access validation through an optional callback. :class:`~octoprint.server.util.tornado.UrlForwardHandler` `tornado.web.RequestHandler `_ that proxies - requests to a preconfigured url and returns the response. + requests to a preconfigured URL and returns the response. :param list server_routes: read-only list of the currently configured server routes :return: a list of 3-tuples with additional routes as defined above :rtype: list +.. _sec-plugins-hook-server-sockjs-authed: + +octoprint.server.sockjs.authed +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: socket_authed_hook(socket, user, *args, **kwargs): + + .. versionadded:: 1.3.10 + + Allows plugins to be notified that a user got authenticated or deauthenticated on the socket (e.g. due to logout). + + :param object socket: the socket object which is about to be registered + :param object user: the user that got authenticated on the socket, or None if the user got deauthenticated + +.. _sec-plugins-hook-server-sockjs-register: + +octoprint.server.sockjs.register +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: socket_registration_hook(socket, user, *args, **kwargs): + + .. versionadded:: 1.3.10 + + Allows plugins to prevent a new :ref:`push socket client ` to be registered to the system. + + Handlers should return either ``True`` or ``False``. ``True`` signals to proceed with normal registration. ``False`` + signals to not register the client. + + :param object socket: the socket object which is about to be registered + :param object user: the user currently authenticated on the socket - might be None + :return: whether to proceed with registration (``True``) or not (``False``) + :rtype: boolean + +.. _sec-plugins-hook-server-sockjs-emit: + +octoprint.server.sockjs.emit +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: socket_emit_hook(socket, user, message, payload, *args, **kwargs): + + .. versionadded:: 1.3.10 + + Allows plugins to prevent any messages to be emitted on an existing :ref:`push connection `. + + Handlers should return either ``True`` to allow the message to be emitted, or ``False`` to prevent it. + + :param object socket: the socket object on which a message is about to be emitted + :param object user: the user currently authenticated on the socket - might be None + :param string message: the message type about to be emitted + :param dict payload: the payload of the message about to be emitted (may be None) + :return: whether to proceed with sending the message (``True``) or not (``False``) + :rtype: boolean + +.. _sec-plugins-hook-system-additional_commands: + +octoprint.system.additional_commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: additional_commands_hook(*args, **kwargs) + + .. versionadded:: 1.7.0 + + Allows adding additional system commands into the system menu. Handlers must return + a list of system command definitions, each definition matching the following data + structure: + + .. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``name`` + - 1 + - String + - The name to display in the menu. + * - ``action`` + - 1 + - String + - An identifier for the action, must only consist of lower case a-z, numbers, ``-`` and ``_`` (``[a-z0-9-_]``). + * - ``command`` + - 1 + - String + - The system command to execute. + * - ``confirm`` + - 0..1 + - String + - An optional message to show as a confirmation dialog before executing the command. + * - ``async`` + - 0..1 + - bool + - If ``True``, the command will be run asynchronously and the API call will return immediately after enqueuing it for execution. + * - ``ignore`` + - 0..1 + - bool + - If ``True``, OctoPrint will ignore the result of the command's (and ``before``'s, if set) execution and return a successful result regardless. Defaults to ``False``. + * - ``debug`` + - 0..1 + - bool + - If ``True``, the command will generate debug output in the log including the command line that's run. Use with care. Defaults to ``False`` + * - ``before`` + - 0..1 + - callable + - Optional callable to execute before the actual ``command`` is run. If ``ignore`` is false and this fails in any way, the command will not run and an error returned. + + .. code-block:: python + + def get_additional_commands(*args, **kwargs): + return [ + { + "name": "Just a test", + "action": "test", + "command": "logger This is just a test of an OctoPrint system command from a plugin", + "before": lambda: print("Hello World!") + } + ] + + __plugin_hooks__ = { + "octoprint.system.additional_commands": get_additional_commands + } + + :return: a list of command specifications + :rtype: list + +.. _sec-plugins-hook-systeminfo-additional_bundle_files: + +octoprint.systeminfo.additional_bundle_files +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: additional_bundle_files_hook(*args, **kwargs) + + .. versionadded:: 1.7.0 + + Allows bundled plugins to extend the list of files to include in the systeminfo bundle. + Note that this hook will ignore third party plugins. Handlers must return a dictionary + mapping file names in the bundle to either local log paths on disk or a ``callable`` + that will be called to generate the file's content inside the bundle. + + **Example** + + Add a plugin's ``console`` log file to the systeminfo bundle: + + .. code-block:: python + + def get_additional_bundle_files(*args, **kwargs): + console_log = self._settings.get_plugin_logfile_path(postfix="console") + return {os.path.basename(console_log): console_log} + + __plugin_hooks__ = { + "octoprint.systeminfo.additional_bundle_files": get_additional_bundle_files + } + + :return: a dictionary mapping bundle file names to bundle file content + :rtype: dict + +.. _sec-plugins-hook-timelapse-extensions: + +octoprint.timelapse.extensions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: timelapse_extension_hook(*args, **kwargs) + + .. versionadded:: 1.3.10 + + Allows extending the set of supported file extensions for timelapse files. Handlers must return a list of + additional file extensions. + + **Example** + + Allow the management of timelapse GIFs with extension ``gif``. + + .. code-block:: python + + def get_timelapse_extensions(*args, **kwargs): + return ["gif"] + + __plugin_hooks__ = { + "octoprint.timelapse.extensions": get_timelapse_extensions + } + + :return: a list of additional file extensions + :rtype: list + .. _sec-plugins-hook-ui-web-templatetypes: octoprint.ui.web.templatetypes @@ -898,6 +1764,8 @@ octoprint.ui.web.templatetypes .. py:function:: templatetype_hook(template_sorting, template_rules, *args, **kwargs) + .. versionadded:: 1.2.0 + Allows extending the set of supported template types in the web interface. This is interesting for plugins which want to offer other plugins to hook into their own offered UIs. Handlers must return a list of additional template specifications in form of 3-tuples. @@ -1004,37 +1872,74 @@ octoprint.ui.web.templatetypes :return: a list of 3-tuples (template type, rule, sorting spec) :rtype: list -.. _sec-plugins-hook-users-factory: +.. _sec-plugins-hook-theming-dialog: -octoprint.users.factory -~~~~~~~~~~~~~~~~~~~~~~~ +octoprint.theming.

+~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. py:function:: user_manager_factory_hook(components, settings, *args, **kwargs) +This actually describes two hooks: - Return a :class:`~octoprint.users.UserManager` instance to use as global user manager object. This will - be called only once during initial server startup. + * ``octoprint.theming.login`` + * ``octoprint.theming.recovery`` - The provided ``components`` is a dictionary containing the already initialized system components: +.. py:function:: ui_theming_hook(*args, **kwargs) - * ``plugin_manager``: The :class:`~octoprint.plugin.core.PluginManager` - * ``printer_profile_manager``: The :class:`~octoprint.printer.profile.PrinterProfileManager` - * ``event_bus``: The :class:`~octoprint.events.EventManager` - * ``analysis_queue``: The :class:`~octoprint.filemanager.analysis.AnalysisQueue` - * ``slicing_manager``: The :class:`~octoprint.slicing.SlicingManager` - * ``file_manager``: The :class:`~octoprint.filemanager.FileManager` - * ``app_session_manager``: The :class:`~octoprint.server.util.flask.AppSessionManager` - * ``plugin_lifecycle_manager``: The :class:`~octoprint.server.LifecycleManager` - * ``preemptive_cache``: The :class:`~octoprint.server.util.flask.PreemptiveCache` + .. versionadded:: 1.5.0 - If the factory returns anything but ``None``, it will be assigned to the global ``userManager`` instance. + Support theming of the login or recovery dialog, just in case the core UI is themed as well. Use to return a list of additional + CSS file URLs to inject into the dialog HTML. - If no of the registered factories return a user manager instance, the class referenced by the ``config.yaml`` - entry ``accessControl.userManager`` will be initialized if possible, otherwise a stock - :class:`~octoprint.users.FilebasedUserManager` will be instantiated, linked to the default user storage - file ``~/.octoprint/users.yaml``. + Example usage by a plugin: - :param dict components: System components to use for user manager instance initialization - :param SettingsManager settings: The global settings manager instance to fetch configuration values from if necessary - :return: The ``userManager`` instance to use globally. - :rtype: UserManager subclass or None + .. code-block:: python + + def loginui_theming(): + from flask import url_for + return [url_for("plugin.myplugin.static", filename="css/loginui_theme.css")] + + __plugin_hooks__ = { + "octoprint.theming.login": loginui_theming + } + + Only a list of ready-made URLs to CSS files is supported, neither LESS nor JS. Best use + ``url_for`` like in the example above to be prepared for any configured prefix URLs. + + :return: A list of additional CSS URLs to inject into the login or recovery dialog. + :rtype: A list of strings. + + +.. _sec-plugins-hook-timelapse-capture-pre: + +octoprint.timelapse.capture.pre +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: capture_pre_hook(filename) + + .. versionadded:: 1.4.0 + + Perform specific actions prior to capturing a timelapse frame. + + ``filename`` will be the future path of the frame to be saved. + + :param str filename: The future path of the frame to be saved. + :return: None + :rtype: None + +.. _sec-plugins-hook-timelapse-capture-post: + +octoprint.timelapse.capture.post +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. py:function:: capture_post_hook(filename, success) + + .. versionadded:: 1.4.0 + + Perform specific actions after capturing a timelapse frame. + + ``filename`` will be the path of the frame that should have been saved. + ``sucesss`` indicates whether the capture was successful or not. + :param str filename: The path of the frame that should have been saved. + :param boolean success: Indicates whether the capture was successful or not. + :return: None + :rtype: None diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index e04033b045..cf662f609c 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -16,3 +16,4 @@ Developing Plugins viewmodels.rst gettingstarted.rst distributing.rst + python3_migration.rst diff --git a/docs/plugins/injectedproperties.rst b/docs/plugins/injectedproperties.rst index 0e063773a4..e531c3bca8 100644 --- a/docs/plugins/injectedproperties.rst +++ b/docs/plugins/injectedproperties.rst @@ -22,7 +22,7 @@ An overview of these properties follows. data files etc). Plugins should not access this property directly but instead utilize :func:`~octoprint.plugin.types.OctoPrintPlugin.get_plugin_data_folder` which will make sure the path actually does exist and if not create it before returning it. ``self._logger`` - A `python logger instance `_ logging to the log target + A :py:class:`logging.Logger` instance logging to the log target ``octoprint.plugin.``. ``self._settings`` The plugin's personalized settings manager, injected only into plugins that include the :class:`~octoprint.plugin.SettingsPlugin` mixin. @@ -41,17 +41,15 @@ An overview of these properties follows. OctoPrint's file manager, an instance of :class:`octoprint.filemanager.FileManager`. ``self._printer`` OctoPrint's printer management object, an instance of :class:`octoprint.printer.PrinterInterface`. -``self._app_session_manager`` - OctoPrint's application session manager, an instance of :class:`octoprint.server.util.flask.AppSessionManager`. ``self._user_manager`` - OctoPrint's user manager, an instance of :class:`octoprint.users.UserManager`. + OctoPrint's user manager, an instance of :class:`octoprint.access.users.UserManager`. ``self._connectivity_checker`` OctoPrint's connectivity checker, an instance of :class:`octoprint.util.ConnectivityChecker`. .. seealso:: :class:`~octoprint.plugin.core.Plugin` and :class:`~octoprint.plugin.types.OctoPrintPlugin` - Class documentation also containing the properties shared among all mixing implementations. + Class documentation also containing the properties shared among all mixin implementations. :ref:`Available Mixins ` Some mixin types trigger the injection of additional properties. diff --git a/docs/plugins/mixins.rst b/docs/plugins/mixins.rst index 95bf3e5cf9..105fb85e29 100644 --- a/docs/plugins/mixins.rst +++ b/docs/plugins/mixins.rst @@ -68,11 +68,14 @@ calls are done is as follows: * Plugins with a return value that is not ``None`` for :meth:`~octoprint.plugin.core.SortablePlugin.get_sorting_key` for the provided sorting context will be ordered among each other first. If the returned order number is equal for - two or more implementations, the plugin's identifier will be the next sorting criteria. - * After that follow plugins which returned ``None`` (the default). They are sorted by their identifier. - -Example: Consider three plugin implementations implementing the :class:`~octoprint.plugin.StartupPlugin` mixin, called -``plugin_a``, ``plugin_b`` and ``plugin_c``. ``plugin_a`` doesn't override :meth:`~octoprint.plugin.core.SortablePlugin.get_sorting_key`. + two or more implementations, they will be sorted first by whether they come bundled with OctoPrint or not, then by + their identifier. + * After that follow plugins which returned ``None`` (the default). They are first sorted by whether they come bundled + with OctoPrint or not, then by their identifier. + +Example: Consider four plugin implementations implementing the :class:`~octoprint.plugin.StartupPlugin` mixin, called +``plugin_a``, ``plugin_b``, ``plugin_c`` and ``plugin_d``, the latter coming bundled with OctoPrint. ``plugin_a`` +and ``plugin_d`` don't override :meth:`~octoprint.plugin.core.SortablePlugin.get_sorting_key`. ``plugin_b`` and ``plugin_c`` both return ``1`` for the sorting context ``StartupPlugin.on_startup``, ``None`` otherwise: .. code-block:: python @@ -99,7 +102,7 @@ Example: Consider three plugin implementations implementing the :class:`~octopri class PluginB(octoprint.plugin.StartupPlugin): - def get_sorting_key(context): + def get_sorting_key(self, context): if context == "StartupPlugin.on_startup": return 1 return None @@ -120,7 +123,7 @@ Example: Consider three plugin implementations implementing the :class:`~octopri class PluginC(octoprint.plugin.StartupPlugin): - def get_sorting_key(context): + def get_sorting_key(self, context): if context == "StartupPlugin.on_startup": return 1 return None @@ -134,13 +137,45 @@ Example: Consider three plugin implementations implementing the :class:`~octopri __plugin_implementation__ = PluginC() +.. code-block:: python + :linenos: + :caption: plugin_d.py + + # in this example this is bundled with OctoPrint + import octoprint.plugin + + class PluginD(octoprint.plugin.StartupPlugin): + + def on_startup(self, *args, **kwargs): + self._logger.info("PluginD starting up") + + def on_after_startup(self, *args, **kwargs): + self._logger.info("PluginD started up") + + __plugin_implementation__ = PluginD() + OctoPrint will detect that ``plugin_b`` and ``plugin_c`` define a order number, and since it's identical for both (``1``) -will order both plugins based on their plugin identifier. ``plugin_a`` doesn't define a sort key and hence will be -put after the other two. The execution order of the ``on_startup`` method will hence be ``plugin_b``, ``plugin_c``, ``plugin_a``. +will order both plugins based first on their bundling status and then on their plugin identifier. +``plugin_a`` and ``plugin_d`` don't define a sort key and hence will be +put after the other two, with ``plugin_d`` coming *before* ``plugin_a`` since it comes bundled with OctoPrint. +The execution order of the ``on_startup`` method will hence be ``plugin_b``, ``plugin_c``, ``plugin_d``, ``plugin_a``. Now, the execution order of the ``on_after_startup`` method will be determined based on another sorting context, ``StartupPlugin.on_after_startup`` for which all of the plugins return ``None``. Hence, the execution order of the -``on_after_startup`` method will be purely ordered by plugin identifier, ``plugin_a``, ``plugin_b``, ``plugin_c``. +``on_after_startup`` method will be ordered first by bundle status, then by plugin identifier: ``plugin_d``, ``plugin_a``, ``plugin_b``, ``plugin_c``. + +This will result in the following messages to be generated: + +.. code-block:: none + + Plugin B starting up + Plugin C starting up + Plugin D starting up + Plugin A starting up + Plugin D started up + Plugin A started up + Plugin B started up + Plugin C started up .. _sec-plugins-mixins-injectedproperties: @@ -153,7 +188,7 @@ An overview of these properties can be found in the section :ref:`Injected Prope .. seealso:: :class:`~octoprint.plugin.core.Plugin` and :class:`~octoprint.plugin.types.OctoPrintPlugin` - Class documentation also containing the properties shared among all mixing implementations. + Class documentation also containing the properties shared among all mixin implementations. .. _sec-plugins-mixins-available: diff --git a/docs/plugins/python3_migration.rst b/docs/plugins/python3_migration.rst new file mode 100644 index 0000000000..170e039e7a --- /dev/null +++ b/docs/plugins/python3_migration.rst @@ -0,0 +1,377 @@ +.. _sec-plugins-python3: + +Migrating to Python 3 +===================== + +Python 2 is now EOL as of January 1st 2020. With the release of 1.4.0 OctoPrint will be compatible to both Python 2 and +Python 3. + +However, the same doesn't automatically hold true for all of the third party plugins for OctoPrint out there - it will +fall to their authors to ensure compatibility to both Python versions. + +This guide is supposed to help plugin authors in making sure their plugins run under Python 2 as well as Python 3, +which for now is the goal for OctoPrint's ecosystem, as we'll have to live with existing legacy Python 2 installations +for a while to come (the plan is to stay Python 2 compatible until roughly a year after the release of 1.4.0). + +.. contents:: + :local: + +.. _sec-plugins-python3-venv: + +How to get a Python 3 virtual environment with OctoPrint +-------------------------------------------------------- + +In order to test your plugins for Python 3 compatibility and also to allow for ongoing maintenance against both Python +versions, you should create a Python 3 virtual environment next to your Python 2 one. You can then quickly switch between +Python 2 and Python 3 simply by ``activate``-ing whichever one you need. + +You can create a Python 3 virtualenv next to your (existing) Python 2 virtualenv and then just activate which one you +currently want to use. + +After installing Python 3 on your development system it's as easy as supplying ``--python=/path/to/python3executable`` +to ``virtualenv``, e.g.: + +.. code-block:: none + + virtualenv --python=/usr/bin/python3 venv3 + +That will have the virtualenv be created based on Python 3, regardless of whether it's currently running under Python +2 or 3. The same works for Python 2 btw: + +.. code-block:: none + + virtualenv --python=/usr/bin/python2 venv2 + +After creating the virtual environment, make sure to activate & install OctoPrint into it: + +.. code-block:: none + + source venv3/bin/activate + pip install "OctoPrint>=1.4.0rc1" + +Then create an editable install of your plugin, start the server and start testing: + +.. code-block:: none + + pip install -e path/to/your/plugin + octoprint serve --debug + +.. note:: + + On Windows that will probably look something like this instead: + + .. code-block:: none + + virtualenv --python=C:/Python37/python.exe venv37 + venv3/Script/activate.bat + pip install "OctoPrint>=1.4.0rc1" + pip install -e path/to/your/plugin + octoprint serve --debug + +.. note:: + + If you want to migrate your existing OctoPrint install *on OctoPi 0.17.0* to Python 3, I suggest to first make a + :ref:`backup `, then move the existing venv ``/home/pi/oprint`` out of the way and + create a new one based on Python 3 (which should already be present on current OctoPi images): + + .. code-block:: none + + mv ~/oprint ~/oprint.py2 + virtualenv --python=/usr/bin/python3 oprint + source ~/oprint/bin/activate + pip install "OctoPrint>=1.4.0" + sudo service octoprint restart + +.. _sec-plugins-python3-markup: + +Telling OctoPrint your plugin is Python 3 ready +----------------------------------------------- + +In order for OctoPrint to even load your plugin when it's running under Python 3, it first needs to know your plugin is +compatible to a Python 3 environment. By default OctoPrint will assume your plugin isn't and refuse to load it when +running under Python 3 itself. + +To tell OctoPrint about this, all you need is to set the ``__plugin_pythoncompat__`` property in your plugins's ``__init__.py`` +accordingly, e.g. + +.. code-block:: python + + __plugin_pythoncompat__ = ">=2.7,<4" + +This would tell OctoPrint that your plugin is compatible to all Python versions between 2.7 and 3.x. This should be +your target compatibility range for now. + +If at a later date you want to go all-in on Python 3 and mark your plugin as no longer supporting Python 2, tell +OctoPrint about this as well: + +.. code-block:: python + + __plugin_pythoncompat__ = ">=3,<4" + +.. note:: + + You can also tell OctoPrint to ignore the Python compatibility flags for a specific plugin via `config.yaml`: + + .. code-block:: yaml + + plugins: + _forcedCompatible: + - "myplugin" + - "anotherplugin" + + Note that this should only be used temporarily during testing and migration, or to mark an important plugin + not under your own control that actually works fine under Python 3 out of the box as compatible while waiting + until the plugin author has pushed an update including the needed flags. Do not just blindly mark third party + plugins as compatible and then open support requests if that causes issues in your setup. + +Once your plugin is ensured to be compatible and you've released a new version that includes the necessary compatibility +flag and changes, is done you also need to mark up your plugin in the Official Plugin Repository (if it's registered +therein) so that OctoPrint's built-in Plugin Manager will see that your plugin is compatible as well and allow users +to install it through it. In order to do that, you need to add a new flag compatibility.python to the front matter in +your plugin registration file and file a pull request for that. Adjust the markdown file so that it contains this: + +.. code-block:: yaml + + compatibility: + python: ">=2.7,<3" + +The value here follows the same mechanism as the ``__plugin_pythoncompat__`` property, so ``>=2.7,<3`` for 2 and 3 +support and ``>=3,<4`` for 3+ support. + +.. warning:: + + Do **not** just mark your plugin as compatible without diligent testing that it actually does work as expected and + without flooding ``octoprint.log`` with warnings and errors! + +.. _sec-plugins-python3-pitfalls: + +Common pitfalls during migration +-------------------------------- + +Some of the changes in Python 3 compared to Python 2 are sadly backwards incompatible and usually cause a number of +common issues in code written for Python 2 when run under Python 3. By now they are pretty well documented and there +exist a number of helpful and comprehensive migration guides, three of which I want to mention here. + +One is the official Python 3 porting guide `Porting Python 2 Code to Python 3 `__ +which sums up all the important changes and also gives hints on how best to go about running a project which supports +both versions for now. + +The second is the `Writing Python 2-3 compatible code `__ cheat sheet +from the Python-Future project, which is a comprehensive list of idioms that are compatible to both Python 2 and 3 and +will make your code run under both, utilizing `future `__ and `six `__. +I can strongly recommend this cheat sheet, it's what primarily guided me during the migration phase as well. + +The third one is the free online book `Support Python 3: An in-depth guide `__, and +especially its chapter on `Common migration problems `__ in which you'll find +extensive descriptions of the most troublesome changes in Python 3 and how to overcome them. Please note that with +regards to the contents of this book, we are aiming for the "Python 2 and Python 3 without conversion" strategy, so +code that runs in both environments. Sadly this book is a bit outdated by now and still references some long-out versions +as "upcoming", so with regards to compatible idioms to use, best stick to the Python-Future cheat sheet. + +Looking at the issues encountered by some plugin authors and also my own experiences during the Python 3 migration of +OctoPrint's code, the most common problems for these scenarios seem to be byte vs unicode issues, trouble with absolute +imports, changes in integer division behaviour and the switch of map, filter and zip to return iterators instead of +lists and causing issues in the following code due to that. + +.. _sec-plugins-python3-pitfalls-strings: + +Bytes vs unicode +................ + +One of if not the most problematic change between Python 2 and 3 surely must be the change in string handling. Under +Python 2 your basic string was a byte string, but it could also magically turn into a unicode string depending on what +you wrote into it. That did cause some confusion, especially in APIs, and caused quite a mess, which is why the decision +was made to go for distinct text and binary types instead, and making the string literal always be a (unicode) text. + +.. note:: + + Please note that these changes in string handling also affect several Python APIs that operate on files and streams + and thus might also affect parts of OctoPrint's plugin interface that inherit from these APIs. Currently only one such + case has been reported, as OctoPrint's :py:class:`~octoprint.filemanager.util.LineProcessorStream` will return bytes + instead of str on its ``process_line`` function under Python 3 - so here's a heads-up if your plugin happens to utilize that. + +Obviously, that will lead to issues in code using "just strings" when run under Python 2 vs 3. The first step to solve +these problems would be to make your scripts behave the same under Python 2 and 3 by putting this right at the top of +all your plugin's python files: + +.. code-block:: python + + from __future__ import unicode_literals + +That will make your files behave as if they were running under Python 3, even when run under Python 2, and your string +literals will now be the text data type, which - annoyingly - is a different one under Python 2 vs 3, ``unicode`` vs ``str`` to +be exact. Heads-up here - under Python 2 there's also a ``str`` type, but that one is for binary data. Yes, I know, this +ain't fun. + +In any case, once you've done this, make sure that everything in your code that should be text is text (``unicode`` under +Python 2, ``str`` under Python 3), and everything that should be binary is binary (``str`` under Python 2, ``bytes`` under Python 3). +A good rule of thumb is that you usually want to use text as much as possible within your application and only convert +to/from bytes at the outskirts, e.g. when writing to a file, a socket or something else machine like. Note that you do +NOT need to convert to bytes when implementing API endpoints that return JSON, as that should use text with unicode +anyhow. + +OctoPrint includes two utility methods you should use to ensure your strings enter/exit your code in the right format, +under both Python versions: :py:func:`octoprint.util.to_bytes` and :py:func:`octoprint.util.to_unicode`. Use them to ensure the correct data +types and to avoid weird conversion and encoding issues during runtime. + +You can read more about this specific issue in the corresponding section of the +`Python porting guide `__ and also in the +`cheat sheet `__. + +.. _sec-plugins-python3-pitfalls-absolute-imports: + +Absolute imports +................ + +Python 3 now defaults to absolute imports, meaning that trying to import a sub package with a + +.. code-block:: python + + import my_sub_package + +will now fail with an error. You'll need to explicitly make the import a relative one: + +.. code-block:: python + + from . import my_sub_package + +To make your code behave the same in that regard unter both Python 2 and Python 3, you should add the corresponding +future import: + +.. code-block:: python + + from __future__ import absolute_imports + +You can read more about this specific issue in the +`cheat sheet `__ and also in +`the book `__. + +.. _sec-plugins-python3-pitfalls-version-specific-imports: + +Version specific imports +........................ + +Sometimes it is necessary to use an import statement that is explicitly related to a specific Python version, e.g. due to +a package change between Python 2 and 3. You can do this by first trying the Python 3 import and if that doesn't work +out trying the Python 2 import instead: + +.. code-block:: python + + try: + import queue + except ImportError: + import Queue as queue + +This should be the preferred method of handling situations like this. If you actually do need to do explicit version +specific imports that cannot be handled this way, you can check for the Python version like this: + +.. code-block:: python + + import sys + if sys.version[0] == '2': + # Python 2 specific imports + else: + # Python 3 specific imports + +.. _sec-plugins-python3-pitfalls-intdiv: + +Integer division +................ + +When you divide two integers in Python 2 you'll get back an integer, rounded down. In Python 3 however you'll now get +a float. That means you might have to revisit some places where you do integer divisions and might rely on the result +to be an integer as well (e.g. when using a calculation result as an index in an array or something like that). + +Yet again there's a future-import to apply to your files in order to at least have them behave the same in that regard +under both Python 2 and Python 3: + +.. code-block:: python + + from __future__ import division + +You can read more about this specific issue in the `Python porting guide `__ +and in the `cheat sheet `__. + +.. _sec-plugins-python3-pitfalls-iterators: + +Iterators instead of list from map, filter, zip +............................................... + +The built-in functions ``map``, ``filter`` and ``zip`` return a ``list`` with their result in Python 2. In Python 3 they have been +switched to returning iterators. That can cause trouble with code handling the result (e.g. if you try to return it as +part of a JSON response on an API endpoint). + +The easiest way to solve this is to make sure to wrap any ``map``/``filter``/``zip`` calls into a ``list`` constructor if the result is +to be used outside of the calling code (even though that comes with a small performance penalty under Python 2): + +.. code-block:: python + + result1 = filter(lambda x: x is not None, my_collection) + result2 = list(filter(lambda x: x is not None, my_collection)) + + assert(isinstance(result1, list)) # Python 2 passes, Python 3 fails + assert(isinstance(result2, list)) # Python 2 and 3 pass + +There also exist further options, take a look at the `cheat sheet `__. + +.. _sec-plugins-python3-checklist: + +Checklist +--------- + +As a summary, follow this checklist to migrate your plugin to be compatible to both Python 2 and 3: + + * Create a Python 3 virtualenv and install OctoPrint and your plugin into it for testing. + * Tell OctoPrint your plugin is Python 2 and 3 compatible by adding a new property ``__plugin_pycompat__`` to its + ``__init__.py``: + + .. code-block:: python + + __plugin_pythoncompat__ = ">=2.7,<4" + + * Add a compatibility header to all `py` files to ensure similar basic behaviour under Python 2 and Python 3: + + .. code-block:: python + + # -*- coding: utf-8 -*- + from __future__ import absolute_import, division, print_function, unicode_literals + + * Thorougly test your plugin under Python 3. Pay special attention to any kind of string handling issues, integer + division, relative imports from your plugin package and how the results of ``map``, ``filter`` and ``zip`` are + used in your code, as those have proven to be the biggest issues during past migrations. + * Once everything works under both Python versions and you've prepared a new release of your plugin (don't forget to + increment the version!), update your registration file in the Official Plugin Repository to include the correct + Python compatibility information as well: + + .. code-block:: yaml + + compatibility: + python: ">=2.7,<4" + +.. _sec-plugins-python3-furtherreading: + +Further reading +--------------- + +.. seealso:: + + `Porting Python 2 Code to Python 3 `__ + The official Python 3 porting guide which sums up all the important changes and also gives hints on how best to + go about running a project which supports both versions for now. + + `Cheat Sheet: Writing Python 2-3 compatible code `__ + A comprehensive list of idioms that are compatible to both Python 2 and 3 and will make your code run under both, + utilizing `future `__ and `six `__. Strongly recommended. + + `Supporting Python 3: An in-depth guide `__ + A free online book on the switch to Python 3. Sadly seems a bit outdated by now, so with regards to compatible + idioms to use, best stick to the cheat sheet. Gives some interesting background however. + + `Towards Python 3 and OctoPrint 1.4.0 `__ + Forum topic discussing OctoPrint 1.4.0's roadmap including Python 3 compatibility and time frame. + + `Migrating plugins to Python 2 & 3 compatibility - experiences? `__ + Forum topic collecting experiences by plugin developers in migrating their plugins to achieve Python 2 & 3 + compatibility. + + diff --git a/docs/plugins/viewmodels.rst b/docs/plugins/viewmodels.rst index 584b745ba4..9c9180b04c 100644 --- a/docs/plugins/viewmodels.rst +++ b/docs/plugins/viewmodels.rst @@ -56,9 +56,9 @@ Example: // more of your view model's implementation } - // we don't explicitely declare a name property here + // we don't explicitly declare a name property here // our view model will be registered under "myCustomViewModel" (implicit - // name derived from contructor name) and "yourCustomViewModel" (explicitely + // name derived from constructor name) and "yourCustomViewModel" (explicitly // provided as additional name) OCTOPRINT_VIEWMODELS.push({ construct: MyCustomViewModel, @@ -125,7 +125,7 @@ gcodeFilesViewModel logViewModel View model for the logfile settings dialog. loginStateViewModel - View model for the current loginstate of the user, very interesting for plugins that need to + View model for the current login state of the user, very interesting for plugins that need to evaluate the current login state or information about the current user, e.g. associated roles. navigationViewModel View model for the navigation bar. @@ -168,7 +168,7 @@ OctoPrint's web application will call several callbacks on all registered view m Those are listed below: onStartup() - Called when the first initialization has been done: All view models are constructed and hence their dependencies + Called when the first initialization has been done. All view models are constructed and hence their dependencies resolved, no bindings have been done yet. onBeforeBinding() @@ -210,7 +210,7 @@ onDataUpdaterPluginMessage(plugin, message) Called when a plugin message is pushed from the server with the identifier of the calling plugin as first and the actual message as the second parameter. Note that the latter might be a full fledged object, depending on the plugin sending the message. You can use this method to asynchronously push data from your plugin's server - component to it's frontend component. + component to its frontend component. onUserLoggedIn(user) Called when a user gets logged into the web app, either passively (upon initial load of the page due to a valid @@ -220,6 +220,17 @@ onUserLoggedIn(user) onUserLoggedOut() Called when a user gets logged out of the web app. +onUserPermissionsChanged(user) + Called when a change in the permissions of the current user is detected. The user data of the just logged in user + will be provided as only parameter. Note that this may also be triggered for not logged in guests if the guest + group is modified. In this case ``user`` will be undefined. + +onBeforePrintStart(callback) + Called before a print is started either by clicking the "Print" button in the state panel or the select & print icon + in the file list. The callback to actually proceed with starting the print is provided as the only parameter. By returning + ``false`` from this, plugins may prevent a print from actually starting, optionally starting it at a later date by + calling ``callback`` themselves. This can be used for example to implement an additional confirmation dialog. + onTabChange(next, current) Called before the main tab view switches to a new tab, so `before` the new tab becomes visible. Called with the next (changed to) and current (still visible) tab's hash (e.g. ``#control``). Note that ``current`` might be undefined @@ -233,6 +244,11 @@ getAdditionalControls() Your view model may return additional custom control definitions for inclusion on the "Control" tab of OctoPrint's interface. See :ref:`the custom control feature`. + .. note:: + + Controls injected from a view model do not support feedback controls (as defined by + ``regex`` and ``template``). + onSettingsShown() Called when the settings dialog is shown. @@ -249,6 +265,10 @@ onUserSettingsShown() onUserSettingsHidden() Called when the user settings dialog is hidden. +onUserSettingsBeforeSave() + Called just before the user settings view model is sent to the server. This is useful, for example, if your plugin + needs to compute persisted settings from a custom view model. + onWizardDetails(response) Called with the response from the wizard detail API call initiated before opening the wizard dialog. Will contain the data from all :class:`~octoprint.plugin.WizardPlugin` implementations returned by their :meth:`~octoprint.plugin.WizardPlugin.get_wizard_details` @@ -313,13 +333,13 @@ Web interface startup sequenceDiagram participant Main - participant onServerConnect - participant fetchSettings - participant bindViewModels participant DataUpdater participant LoginStateViewModel + participant SettingsViewModel + participant UiStateViewModel - Note right of DataUpdater: connectCallback = undefined + Note over DataUpdater: connectCallback = undefined + Note over UiStateViewModel: loaded = false activate Main @@ -337,52 +357,60 @@ Web interface startup Main->>+DataUpdater: connectCallback = onServerConnect Note right of DataUpdater: connectCallback = onServerConnect DataUpdater-->>-Main: ok - Main->>+onServerConnect: call - onServerConnect->>+LoginStateViewModel: passiveLogin - LoginStateViewModel-->>onServerConnect: ok - onServerConnect-->>Main: ok - deactivate onServerConnect + Main->>+Main: onServerConnect + Main->>+LoginStateViewModel: passiveLogin + LoginStateViewModel-->>Main: ok + Main-->>Main: ok + deactivate Main deactivate Main LoginStateViewModel->>+LoginStateViewModel: asynchronous passive login - Note over Main,LoginStateViewModel: Session available! - LoginStateViewModel-X+onServerConnect: done + Note over Main,UiStateViewModel: Session available! + LoginStateViewModel-X+Main: done deactivate LoginStateViewModel deactivate LoginStateViewModel - onServerConnect->>+DataUpdater: initialized + Main->>+DataUpdater: initialized Note right of DataUpdater: initialized = true DataUpdater->DataUpdater: trigger stored callbacks - DataUpdater-->>-onServerConnect: ok - onServerConnect-X+Main: done - deactivate onServerConnect + DataUpdater-->>-Main: ok - Main->>+fetchSettings: call - Note right of fetchSettings: trigger onStartup + Main->>+Main: fetchSettings + Note right of Main: trigger onStartup - fetchSettings-->>Main: ok + Main->>+SettingsViewModel: requestData + SettingsViewModel-->>Main: ok + deactivate Main deactivate Main - fetchSettings->>+fetchSettings: asynchronous settings fetch - fetchSettings->>+bindViewModels: call + SettingsViewModel->>+SettingsViewModel: asynchronous settings fetch + Note over Main,UiStateViewModel: Settings available! + SettingsViewModel-X+Main: done + deactivate SettingsViewModel + deactivate SettingsViewModel + + Main->>+Main: bindViewModels loop for each view model - bindViewModels->bindViewModels: trigger onBeforeBinding - bindViewModels->bindViewModels: trigger onBoundTo - bindViewModels->bindViewModels: trigger onAfterBinding + Main->Main: trigger onBeforeBinding + Main->Main: trigger onBoundTo + Main->Main: trigger onAfterBinding end - bindViewModels->bindViewModels: trigger onAllBound + Main->Main: trigger onAllBound opt User is logged in - bindViewModels->>+LoginStateViewModel: onAllBound + Main->>+LoginStateViewModel: onAllBound LoginStateViewModel->LoginStateViewModel: trigger onUserLoggedIn - LoginStateViewModel-->>-bindViewModels: ok + LoginStateViewModel-->>-Main: ok end - bindViewModels->bindViewModels: trigger onStartupComplete - bindViewModels-->>-fetchSettings: ok - deactivate fetchSettings - deactivate fetchSettings + Main->>+UiStateViewModel: loaded + Note right of UiStateViewModel: loaded = true + UiStateViewModel-->>-Main: ok + + Main->Main: trigger onStartupComplete + deactivate Main + deactivate Main .. _sec-plugins-viewmodels-reconnect: @@ -425,7 +453,7 @@ Web interface reconnect .. seealso:: - `OctoPrint's core viewmodels `_ + `OctoPrint's core viewmodels `_ OctoPrint's own view models use the same mechanisms for interacting with each other and the web application as plugins. Their source code is therefore a good point of reference on how to achieve certain things. `KnockoutJS documentation `_ diff --git a/docs/sphinxext/codeblockext.py b/docs/sphinxext/codeblockext.py index a923c0f87c..cfbbadbb02 100644 --- a/docs/sphinxext/codeblockext.py +++ b/docs/sphinxext/codeblockext.py @@ -1,18 +1,16 @@ -# coding=utf-8 -from __future__ import absolute_import +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals __author__ = "Gina Häußge " -__license__ = 'The MIT License ' +__license__ = "The MIT License " __copyright__ = "Copyright (C) 2015 Gina Häußge - Released under terms of the MIT License" from sphinx.directives.code import CodeBlock import sphinx.highlighting -from sphinx.highlighting import PygmentsBridge from sphinx.ext import doctest -from sphinx.util.texescape import tex_hl_escape_map_new -from docutils.nodes import General, FixedTextElement, literal_block, container +from docutils import nodes from docutils.parsers.rst import directives from six import text_type @@ -22,237 +20,264 @@ from pygments.lexers.python import PythonConsoleLexer from pygments.util import ClassNotFound +if False: + # For type annotation + from typing import Any, Dict, List, Tuple # NOQA + + def _merge_dict(a, b): - """ - Little helper to merge two dicts a and b on the fly. - """ - result = dict(a) - result.update(b) - return result - -class literal_block_ext(General, FixedTextElement): - """ - Custom node which is basically the same as a :class:`literal_block`, just with whitespace support and introduced - in order to be able to have a custom visitor. - """ - - @classmethod - def from_literal_block(cls, block): - """ - Factory method constructing an instance exactly copying all attributes over from ``block`` and settings a - custom ``tagname``. - """ - new = literal_block_ext() - for a in ("attributes", "basic_attributes", "child_text_separator", "children", "document", "known_attributes", - "line", "list_attributes", "local_attributes", "parent", "rawsource", "source"): - setattr(new, a, getattr(block, a)) - new.tagname = "literal_block_ext" - return new + """ + Little helper to merge two dicts a and b on the fly. + """ + result = dict(a) + result.update(b) + return result + + +class literal_block_ext(nodes.General, nodes.FixedTextElement): + """ + Custom node which is basically the same as a :class:`literal_block`, just with whitespace support and introduced + in order to be able to have a custom visitor. + """ + + @classmethod + def from_literal_block(cls, block): + """ + Factory method constructing an instance exactly copying all attributes over from ``block`` and settings a + custom ``tagname``. + """ + new = literal_block_ext() + for a in ( + "attributes", + "basic_attributes", + "child_text_separator", + "children", + "document", + "known_attributes", + "line", + "list_attributes", + "local_attributes", + "parent", + "rawsource", + "source", + ): + setattr(new, a, getattr(block, a)) + new.tagname = "literal_block_ext" + return new + class CodeBlockExt(CodeBlock): - """ - This is basically an extension of a regular :class:`CodeBlock` directive which just supports an additional option - ``whitespace`` which if present will enable (together with everything else in here) to render whitespace in - code blocks. - """ - - option_spec = _merge_dict(CodeBlock.option_spec, dict(whitespace=directives.flag)) - - def run(self): - # get result from parent implementation - code_block = CodeBlock.run(self) - - def find_and_wrap_literal_block(node): - """ - Recursive method to turn all literal blocks located within a node into :class:`literal_block_ext`. - """ - - if isinstance(node, container): - # container node => handle all children - children = [] - for child in node.children: - children.append(find_and_wrap_literal_block(child)) - node.children = children - return node - - elif isinstance(node, literal_block): - # literal block => replace it - return self._wrap_literal_block(node) - - else: - # no idea what that is => leave it alone - return node - - # replace all created literal_blocks with literal_block_ext instances - return map(find_and_wrap_literal_block, code_block) - - def _wrap_literal_block(self, node): - literal = literal_block_ext.from_literal_block(node) - literal["whitespace"] = "whitespace" in self.options - return literal + """ + This is basically an extension of a regular :class:`CodeBlock` directive which just supports an additional option + ``whitespace`` which if present will enable (together with everything else in here) to render whitespace in + code blocks. + """ + + option_spec = _merge_dict(CodeBlock.option_spec, {"whitespace": directives.flag}) + + def run(self): + # type: () -> List[nodes.Node] + # get result from parent implementation + code_block = CodeBlock.run(self) + + def find_and_wrap_literal_block(node): + """ + Recursive method to turn all literal blocks located within a node into :class:`literal_block_ext`. + """ + + if isinstance(node, nodes.container): + # container node => handle all children + children = [] + for child in node.children: + children.append(find_and_wrap_literal_block(child)) + node.children = children + return node + + elif isinstance(node, nodes.literal_block): + # literal block => replace it + return self._wrap_literal_block(node) + + else: + # no idea what that is => leave it alone + return node + + # replace all created literal_blocks with literal_block_ext instances + return list(map(find_and_wrap_literal_block, code_block)) + + def _wrap_literal_block(self, node): + literal = literal_block_ext.from_literal_block(node) + literal["whitespace"] = "whitespace" in self.options + return literal + class PygmentsBridgeExt(object): - """ - Wrapper for :class:`PygmentsBridge`, delegates everything to the wrapped ``bridge`` but :method:`highlight_block`, - which calls the parent implementation for lexer selection, then - """ - - def __init__(self, bridge, whitespace): - self._bridge = bridge - self._whitespace = whitespace - - def __getattr__(self, item): - return getattr(self._bridge, item) - - def highlight_block(self, source, lang, opts=None, warn=None, force=False, **kwargs): - if not self._whitespace: - return self._bridge.highlight_block(source, lang, opts=opts, warn=warn, force=force, **kwargs) - - # We are still here => we need to basically do everything the parent implementation does (and does so in a very - # inextensible way...), but inject the whitespace filter into the used lexer just before the highlighting run - # and remove it afterwards so the lexer can be safely reused. - # - # For this we define a context manager that will allow us to wrap a lexer and modify its filters on the fly to - # include the whitespace filter. - - class whitespace(object): - def __init__(self, lexer): - self._lexer = lexer - self._orig_filters = lexer.filters - self._orig_tabsize = lexer.tabsize - - def __enter__(self): - new_filters = list(self._orig_filters) - new_filters.append(VisibleWhitespaceFilter(spaces=True, tabs=True, tabsize=self._lexer.tabsize)) - self._lexer.filters = new_filters - self._lexer.tabsize = 0 - return self._lexer - - def __exit__(self, type, value, traceback): - self._lexer.filters = self._orig_filters - self._lexer.tabsize = self._orig_tabsize - - # Then a ton of copy-pasted code follows. Sadly, we need to do this since we have no way to inject ourselves - # into the highlighting call otherwise - lexer selection and actual call are tightly coupled in the original - # "highlight_block" method, with no means for external code to inject different functionality. - # - # Unless otherwise marked ("MODIFIED"), any code in this method after this line is copied verbatim from the - # implementation of sphinx.highlighting.PygmentsBridge, released under the Simplified BSD License, the copyright - # lies with the respective authors. - - if not isinstance(source, text_type): - source = source.decode() - - # find out which lexer to use - if lang in ('py', 'python'): - if source.startswith('>>>'): - # interactive session - lexer = sphinx.highlighting.lexers['pycon'] - elif not force: - # maybe Python -- try parsing it - if self.try_parse(source): - lexer = sphinx.highlighting.lexers['python'] - else: - lexer = sphinx.highlighting.lexers['none'] - else: - lexer = sphinx.highlighting.lexers['python'] - elif lang in ('python3', 'py3') and source.startswith('>>>'): - # for py3, recognize interactive sessions, but do not try parsing... - lexer = sphinx.highlighting.lexers['pycon3'] - elif lang == 'guess': - try: - lexer = sphinx.highlighting.guess_lexer(source) - except Exception: - lexer = sphinx.highlighting.lexers['none'] - else: - if lang in sphinx.highlighting.lexers: - lexer = sphinx.highlighting.lexers[lang] - else: - try: - lexer = sphinx.highlighting.lexers[lang] = sphinx.highlighting.get_lexer_by_name(lang, **opts or {}) - except ClassNotFound: - if warn: - warn('Pygments lexer name %r is not known' % lang) - lexer = sphinx.highlighting.lexers['none'] - else: - raise - else: - lexer.add_filter('raiseonerror') - - if not isinstance(source, text_type): - source = source.decode() - - # trim doctest options if wanted - if isinstance(lexer, PythonConsoleLexer) and self._bridge.trim_doctest_flags: - source = doctest.blankline_re.sub('', source) - source = doctest.doctestopt_re.sub('', source) - - # highlight via Pygments - formatter = self._bridge.get_formatter(**kwargs) - try: - # MODIFIED: replaced by whitespace wrapped call - with whitespace(lexer) as l: - hlsource = highlight(source, l, formatter) - # /MODIFIED - except ErrorToken: - # this is most probably not the selected language, - # so let it pass unhighlighted - - # MODIFIED: replaced by whitespace wrapped call - with whitespace(sphinx.highlighting.lexers["none"]) as l: - hlsource = highlight(source, l, formatter) - # /MODIFIED - if self._bridge.dest == 'html': - return hlsource - else: - if not isinstance(hlsource, text_type): # Py2 / Pygments < 1.6 - hlsource = hlsource.decode() - return hlsource.translate(tex_hl_escape_map_new) + """ + Wrapper for :class:`PygmentsBridge`, delegates everything to the wrapped ``bridge`` but :method:`highlight_block`, + which calls the parent implementation for lexer selection, then + """ + + def __init__(self, bridge, whitespace): + self._bridge = bridge + self._whitespace = whitespace + + def __getattr__(self, item): + return getattr(self._bridge, item) + + def highlight_block(self, source, lang, opts=None, warn=None, force=False, **kwargs): + if not self._whitespace: + return self._bridge.highlight_block( + source, lang, opts=opts, warn=warn, force=force, **kwargs + ) + + # We are still here => we need to basically do everything the parent implementation does (and does so in a very + # inextensible way...), but inject the whitespace filter into the used lexer just before the highlighting run + # and remove it afterwards so the lexer can be safely reused. + # + # For this we define a context manager that will allow us to wrap a lexer and modify its filters on the fly to + # include the whitespace filter. + + class whitespace(object): + def __init__(self, lexer): + self._lexer = lexer + self._orig_filters = lexer.filters + self._orig_tabsize = lexer.tabsize + + def __enter__(self): + new_filters = list(self._orig_filters) + new_filters.append( + VisibleWhitespaceFilter( + spaces=True, tabs=True, tabsize=self._lexer.tabsize + ) + ) + self._lexer.filters = new_filters + self._lexer.tabsize = 0 + return self._lexer + + def __exit__(self, type, value, traceback): + self._lexer.filters = self._orig_filters + self._lexer.tabsize = self._orig_tabsize + + # Then a ton of copy-pasted code follows. Sadly, we need to do this since we have no way to inject ourselves + # into the highlighting call otherwise - lexer selection and actual call are tightly coupled in the original + # "highlight_block" method, with no means for external code to inject different functionality. + # + # Unless otherwise marked ("MODIFIED"), any code in this method after this line is copied verbatim from the + # implementation of sphinx.highlighting.PygmentsBridge, released under the Simplified BSD License, the copyright + # lies with the respective authors. + + if not isinstance(source, text_type): + source = source.decode() + + # find out which lexer to use + if lang in ("py", "python"): + if source.startswith(">>>"): + # interactive session + lexer = sphinx.highlighting.lexers["pycon"] + elif not force: + # maybe Python -- try parsing it + if self.try_parse(source): + lexer = sphinx.highlighting.lexers["python"] + else: + lexer = sphinx.highlighting.lexers["none"] + else: + lexer = sphinx.highlighting.lexers["python"] + elif lang in ("python3", "py3") and source.startswith(">>>"): + # for py3, recognize interactive sessions, but do not try parsing... + lexer = sphinx.highlighting.lexers["pycon3"] + elif lang == "guess": + # try: + lexer = sphinx.highlighting.guess_lexer(source) + # except Exception: + # lexer = sphinx.highlighting.lexers['none'] + else: + if lang in sphinx.highlighting.lexers: + lexer = sphinx.highlighting.lexers[lang] + else: + try: + lexer = sphinx.highlighting.lexers[ + lang + ] = sphinx.highlighting.get_lexer_by_name(lang, **opts or {}) + except ClassNotFound: + if warn: + warn("Pygments lexer name %r is not known" % lang) + lexer = sphinx.highlighting.lexers["none"] + else: + raise + else: + lexer.add_filter("raiseonerror") + + if not isinstance(source, text_type): + source = source.decode() + + # trim doctest options if wanted + if isinstance(lexer, PythonConsoleLexer) and self._bridge.trim_doctest_flags: + source = doctest.blankline_re.sub("", source) + source = doctest.doctestopt_re.sub("", source) + + # highlight via Pygments + formatter = self._bridge.get_formatter(**kwargs) + try: + # MODIFIED: replaced by whitespace wrapped call + with whitespace(lexer) as l: + hlsource = highlight(source, l, formatter) + # /MODIFIED + except ErrorToken: + # this is most probably not the selected language, + # so let it pass unhighlighted + + # MODIFIED: replaced by whitespace wrapped call + with whitespace(sphinx.highlighting.lexers["none"]) as l: + hlsource = highlight(source, l, formatter) + # /MODIFIED + return hlsource class whitespace_highlighter(object): - """ - Context manager for adapting the used highlighter on a translator for a given node's whitespace properties. - """ - def __init__(self, translator, node): - self.translator = translator - self.node = node + """ + Context manager for adapting the used highlighter on a translator for a given node's whitespace properties. + """ + + def __init__(self, translator, node): + self.translator = translator + self.node = node - self._orig_highlighter = self.translator.highlighter + self._orig_highlighter = self.translator.highlighter - def __enter__(self): - whitespace = self.node["whitespace"] if "whitespace" in self.node else False - if whitespace: - self.translator.highlighter = PygmentsBridgeExt(self._orig_highlighter, whitespace) - return self.translator + def __enter__(self): + whitespace = self.node["whitespace"] if "whitespace" in self.node else False + if whitespace: + self.translator.highlighter = PygmentsBridgeExt( + self._orig_highlighter, whitespace + ) + return self.translator - def __exit__(self, exc_type, exc_val, exc_tb): - self.translator.highlighter = self._orig_highlighter + def __exit__(self, exc_type, exc_val, exc_tb): + self.translator.highlighter = self._orig_highlighter def visit_literal_block_ext(translator, node): - """ - When our custom code block is visited, we temporarily exchange the highlighter used in the translator, call the - visitor for regular literal blocks, then switch back again. - """ - with whitespace_highlighter(translator, node): - translator.visit_literal_block(node) + """ + When our custom code block is visited, we temporarily exchange the highlighter used in the translator, call the + visitor for regular literal blocks, then switch back again. + """ + with whitespace_highlighter(translator, node): + translator.visit_literal_block(node) def depart_literal_block_ext(translator, node): - """ - Just call the depart function for regular literal blocks. - """ - with whitespace_highlighter(translator, node): - translator.depart_literal_block(node) + """ + Just call the depart function for regular literal blocks. + """ + with whitespace_highlighter(translator, node): + translator.depart_literal_block(node) def setup(app): - # custom directive - app.add_directive("code-block-ext", CodeBlockExt) + # custom directive + app.add_directive("code-block-ext", CodeBlockExt) - # custom node type - handler = (visit_literal_block_ext, depart_literal_block_ext) - app.add_node(literal_block_ext, html=handler, latex=handler, text=handler) + # custom node type + handler = (visit_literal_block_ext, depart_literal_block_ext) + app.add_node(literal_block_ext, html=handler, latex=handler, text=handler) - return dict(version="0.1") \ No newline at end of file + return {"version": "0.1"} diff --git a/docs/sphinxext/onlineinclude.py b/docs/sphinxext/onlineinclude.py index b6d9a1810a..5344e34732 100644 --- a/docs/sphinxext/onlineinclude.py +++ b/docs/sphinxext/onlineinclude.py @@ -1,8 +1,8 @@ -# coding=utf-8 -from __future__ import absolute_import +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals __author__ = "Gina Häußge " -__license__ = 'The MIT License ' +__license__ = "The MIT License " __copyright__ = "Copyright (C) 2015 Gina Häußge - Released under terms of the MIT License" @@ -10,105 +10,118 @@ import requests from contextlib import closing -from sphinx.directives.code import LiteralIncludeReader, LiteralInclude, dedent_lines, \ - nodes, set_source_info, parselinenos, logger, container_wrapper +from sphinx.directives.code import ( + LiteralIncludeReader, + LiteralInclude, + dedent_lines, + nodes, + parselinenos, + logger, + container_wrapper, +) +from sphinx.util.nodes import set_source_info if False: - # For type annotation - from typing import Any, List # NOQA + # For type annotation + from typing import Any, List # NOQA -cache = dict() +cache = {} -class OnlineIncludeReader(LiteralIncludeReader): - - def read_file(self, filename, location=None): - # type: (unicode, Any) -> List[unicode] - - global cache - try: - if filename in cache: - lines = cache[filename] - else: - with closing(requests.get(filename, stream=True)) as r: - r.encoding = self.encoding - lines = r.text.splitlines(True) - cache[filename] = lines - - if 'tab-width' in self.options: - lines = [line.expandtabs(self.options['tab-width']) for line in lines] - return lines - except (IOError, OSError): - raise IOError(_('Include file %r not found or reading it failed') % filename) - except UnicodeError: - raise UnicodeError(_('Encoding %r used for reading included file %r seems to ' - 'be wrong, try giving an :encoding: option') % - (self.encoding, filename)) +class OnlineIncludeReader(LiteralIncludeReader): + def read_file(self, filename, location=None): + # type: (unicode, Any) -> List[unicode] + + global cache + try: + if filename in cache: + lines = cache[filename] + else: + with closing(requests.get(filename, stream=True)) as r: + r.encoding = self.encoding + lines = r.text.splitlines(True) + cache[filename] = lines + + if "tab-width" in self.options: + lines = [line.expandtabs(self.options["tab-width"]) for line in lines] + + return lines + except (IOError, OSError): + raise IOError("Include file %r not found or reading it failed" % filename) + except UnicodeError: + raise UnicodeError( + "Encoding %r used for reading included file %r seems to " + "be wrong, try giving an :encoding: option" % (self.encoding, filename) + ) class OnlineIncludeDirective(LiteralInclude): - - def run(self): - # type: () -> List[nodes.Node] - document = self.state.document - if not document.settings.file_insertion_enabled: - return [document.reporter.warning('File insertion disabled', - line=self.lineno)] - env = document.settings.env - - # convert options['diff'] to absolute path - if 'diff' in self.options: - _, path = env.relfn2path(self.options['diff']) - self.options['diff'] = path - - try: - location = self.state_machine.get_source_and_line(self.lineno) - url = self.arguments[0] - - reader = OnlineIncludeReader(url, self.options, env.config) - text, lines = reader.read(location=location) - - retnode = nodes.literal_block(text, text, source=url) - set_source_info(self, retnode) - if self.options.get('diff'): # if diff is set, set udiff - retnode['language'] = 'udiff' - elif 'language' in self.options: - retnode['language'] = self.options['language'] - retnode['linenos'] = ('linenos' in self.options or - 'lineno-start' in self.options or - 'lineno-match' in self.options) - retnode['classes'] += self.options.get('class', []) - extra_args = retnode['highlight_args'] = {} - if 'emphasize-lines' in self.options: - hl_lines = parselinenos(self.options['emphasize-lines'], lines) - if any(i >= lines for i in hl_lines): - logger.warning('line number spec is out of range(1-%d): %r' % - (lines, self.options['emphasize-lines']), - location=location) - extra_args['hl_lines'] = [x + 1 for x in hl_lines if x < lines] - extra_args['linenostart'] = reader.lineno_start - - if 'caption' in self.options: - caption = self.options['caption'] or self.arguments[0] - retnode = container_wrapper(self, retnode, caption) - - # retnode will be note_implicit_target that is linked from caption and numref. - # when options['name'] is provided, it should be primary ID. - self.add_name(retnode) - - return [retnode] - except Exception as exc: - return [document.reporter.warning(str(exc), line=self.lineno)] + def run(self): + # type: () -> List[nodes.Node] + document = self.state.document + if not document.settings.file_insertion_enabled: + return [ + document.reporter.warning("File insertion disabled", line=self.lineno) + ] + # convert options['diff'] to absolute path + if "diff" in self.options: + _, path = self.env.relfn2path(self.options["diff"]) + self.options["diff"] = path + + try: + location = self.state_machine.get_source_and_line(self.lineno) + url = self.arguments[0] + + reader = OnlineIncludeReader(url, self.options, self.config) + text, lines = reader.read(location=location) + + retnode = nodes.literal_block(text, text, source=url) + set_source_info(self, retnode) + if self.options.get("diff"): # if diff is set, set udiff + retnode["language"] = "udiff" + elif "language" in self.options: + retnode["language"] = self.options["language"] + retnode["linenos"] = ( + "linenos" in self.options + or "lineno-start" in self.options + or "lineno-match" in self.options + ) + retnode["classes"] += self.options.get("class", []) + extra_args = retnode["highlight_args"] = {} + if "emphasize-lines" in self.options: + hl_lines = parselinenos(self.options["emphasize-lines"], lines) + if any(i >= lines for i in hl_lines): + logger.warning( + "line number spec is out of range(1-%d): %r" + % (lines, self.options["emphasize-lines"]), + location=location, + ) + extra_args["hl_lines"] = [x + 1 for x in hl_lines if x < lines] + extra_args["linenostart"] = reader.lineno_start + + if "caption" in self.options: + caption = self.options["caption"] or self.arguments[0] + retnode = container_wrapper(self, retnode, caption) + + # retnode will be note_implicit_target that is linked from caption and numref. + # when options['name'] is provided, it should be primary ID. + self.add_name(retnode) + + return [retnode] + except Exception as exc: + return [document.reporter.warning(str(exc), line=self.lineno)] def visit_onlineinclude(translator, node): - translator.visit_literal_block(node) + translator.visit_literal_block(node) + def depart_onlineinclude(translator, node): - translator.depart_literal_block(node) + translator.depart_literal_block(node) + def setup(app): - app.add_directive("onlineinclude", OnlineIncludeDirective) + app.add_directive("onlineinclude", OnlineIncludeDirective) - handler = (visit_onlineinclude, depart_onlineinclude) - app.add_node(OnlineIncludeDirective, html=handler, latex=handler, text=handler) + handler = (visit_onlineinclude, depart_onlineinclude) + app.add_node(OnlineIncludeDirective, html=handler, latex=handler, text=handler) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000..597e09bdbe --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = src tests +addopts = --doctest-modules + --doctest-repr=octoprint.util:pp + --ignore=src/octoprint/vendor diff --git a/run b/run index 1145a24fd4..07c49175a6 100755 --- a/run +++ b/run @@ -1,4 +1,5 @@ -#!/usr/bin/env python2 +#!/usr/bin/env python +from __future__ import absolute_import, division, print_function, unicode_literals import os import sys @@ -8,15 +9,20 @@ basedir = os.path.dirname(os.path.realpath(__file__)) old = os.path.join(basedir, "octoprint") if os.path.exists(old): # rename left-overs from old file structure - print """ + print( + """ Found left-overs from old file structure, renaming to "octoprint.backup". Please remove this manually (I don't dare to do so myself since you might have changes in there I don't know anything about). """ + ) os.rename(old, os.path.join(basedir, "octoprint.backup")) sys.path.insert(0, os.path.join(basedir, "src")) -import octoprint +import octoprint # noqa: E402 + +if len(sys.argv) == 1: + sys.argv.append("serve") octoprint.main() diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000000..14792931bf --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,39 @@ +# init/systemd files for OctoPrint. + +Assumes OctoPrint is installed under user pi at /home/pi/OctoPrint/venv/bin/octoprint. If you have a different +setup you'll need to adjust octoprint.default (init) or octoprint.service (systemd) accordingly. + +## init +Download the init.d files to the locations shown: + +``` +octoprint.default => /etc/default/octoprint +octoprint.init => /etc/init.d/octoprint +``` + +Next, enable and start the `octoprint` service: + +```sh +# Enable octoprint service +sudo update-rc.d octoprint defaults + +# and start it +sudo service octoprint start +``` + +## systemd +Download the systemd files to the locations shown: + +``` +octoprint.service => /etc/systemd/system/octoprint.service +``` + +Next, enable and start the `octoprint` service: + +```sh +# Enable octoprint service +sudo systemctl enable octoprint + +# and start it +sudo systemctl start octoprint +``` diff --git a/scripts/octoprint.service b/scripts/octoprint.service new file mode 100644 index 0000000000..ae5405c34a --- /dev/null +++ b/scripts/octoprint.service @@ -0,0 +1,14 @@ +[Unit] +Description=The snappy web interface for your 3D printer +After=network-online.target +Wants=network-online.target + +[Service] +Environment="LC_ALL=C.UTF-8" +Environment="LANG=C.UTF-8" +Type=exec +User=pi +ExecStart=/home/pi/OctoPrint/venv/bin/octoprint + +[Install] +WantedBy=multi-user.target diff --git a/setup.cfg b/setup.cfg index 9be46c443c..0c05bd6ced 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,11 +1,35 @@ [metadata] -description-file = README.md +license_file = LICENSE.txt + +[bdist_wheel] +universal = 0 [versioneer] VCS = git -style = pep440-tag +style = pep440 versionfile_source = src/octoprint/_version.py versionfile_build = octoprint/_version.py -tag_prefix = +tag_prefix = v parentdir_prefix = lookupfile = .versioneer-lookup + +[flake8] +max-line-length = 90 +extend-ignore = E203, E231, E265, E266, E402, E501, E731 +select = B,C,E,F,W,T4,B9 +exclude = + src/octoprint/vendor + +[isort] +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +line_length = 90 +skip = + src/octoprint/vendor +known_first_party = + octoprint + octoprint_setuptools + octoprint_client diff --git a/setup.py b/setup.py index c9893ce1f1..f8ffb1d00b 100644 --- a/setup.py +++ b/setup.py @@ -1,217 +1,159 @@ -#!/usr/bin/env python2 -# coding=utf-8 +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- -from setuptools import setup, find_packages -from distutils.command.build_py import build_py as _build_py import os +import sys +from setuptools import setup, find_packages import versioneer -import sys +# Ensure src is in path for octoprint_setuptools sys.path.insert(0, os.path.join(os.path.dirname(os.path.realpath(__file__)), "src")) import octoprint_setuptools -#----------------------------------------------------------------------------------------------------------------------- +# Runtime baseline for this fork +PYTHON_REQUIRES = ">=3.10,<4" + +# Requirements for setup.py +SETUP_REQUIRES = ["markdown>=3.7,<4"] -# Requirements for our application +# Modernized requirements for Python 3.10+ +# Pinned to versions compatible with Python 3.13 (removes 'imp' and 'pathtools' dependencies) INSTALL_REQUIRES = [ - "flask>=0.9,<0.11", - "Jinja2>=2.8,<2.9", # Jinja 2.9 has breaking changes WRT template scope - we can't - # guarantee backwards compatibility for plugins and such with that - # version, hence we need to pin to a lower version for now. See #1697 - "werkzeug>=0.8.3,<0.9", - "tornado==4.0.2", # pinned for now, we need to migrate to a newer tornado, but due - # to some voodoo needed to get large streamed uploads and downloads - # to work that is probably not completely straightforward and therefore - # something for post-1.3.0-stable release - "sockjs-tornado>=1.0.3,<1.1", - "PyYAML>=3.10,<3.11", - "Flask-Login>=0.2.2,<0.3", - "Flask-Principal>=0.3.5,<0.4", - "Flask-Babel>=0.9,<0.10", - "Flask-Assets>=0.10,<0.11", - "markdown>=2.6.4,<2.7", - "pyserial>=2.7,<2.8", - "netaddr>=0.7.17,<0.8", - "watchdog>=0.8.3,<0.9", - "sarge==0.1.4", - "netifaces>=0.10,<0.11", - "pylru>=1.0.9,<1.1", - "rsa>=3.2,<3.3", - "pkginfo>=1.2.1,<1.3", - "requests>=2.18.4,<3", - "semantic_version>=2.4.2,<2.5", - "psutil>=5.4.1,<6", - "Click>=6.2,<6.3", - "awesome-slugify>=1.6.5,<1.7", - "feedparser>=5.2.1,<5.3", - "chainmap>=1.0.2,<1.1", - "future>=0.15,<0.16", - "scandir>=1.3,<1.4", - "websocket-client>=0.40,<0.41", - "python-dateutil>=2.6,<2.7", - "wrapt>=1.10.10,<1.11", - "futures>=3.1.1,<3.2", - "emoji>=0.4.5,<0.5", - "monotonic>=1.3,<1.4" + "packaging>=23.2", + "setuptools", + "OctoPrint-FileCheck>=2021.2.23", + "OctoPrint-FirmwareCheck>=2021.10.11", + "OctoPrint-PiSupport>=2021.10.28", + + "markupsafe>=2.1.5,<3", + "markdown>=3.7,<4", + "wrapt>=1.17.2,<1.18", + + "flask>=2.2.5,<2.3.0", # Downgraded from 3.0.3 + "werkzeug>=2.3.8,<3.0.0", # Downgraded to match Flask 2.2 + "Jinja2>=3.1.2,<4", + "itsdangerous>=2.1.2,<3", + + "Flask-Login>=0.6.3,<0.7", + "Flask-Babel>=2.0.0,<3.0.0", + "Flask-Assets>=2.1.0,<3", + "webassets>=2.0,<3", + "cachelib>=0.13.0,<0.14", + + "tornado>=6.2,<7", # Missing: Required for the web server + "future>=1.0.0,<2", # Missing: Required for legacy Py3 compatibility imports + "frozendict>=2.4.4,<3", # Missing: Required by internal logic + + "feedparser>=6.0.10,<7", + "unidecode>=1.3.8,<2", + "regex>=2024.5.15", # Helps resolve those SyntaxWarnings in the vendor folder + + "PyYAML>=6.0.1,<7", + "pyserial>=3.5,<4", + "netaddr>=1.3.0,<1.4", + "watchdog>=4.0.2,<5", # Fixes the Python 3.12/3.13 'imp' crash + "sarge==0.1.7.post1", + "netifaces>=0.11,<1", + "pylru>=1.2,<2", + "pkginfo>=1.12,<2", + "requests>=2.32.0,<3", + "semantic_version>=2.10,<3", + "psutil>=6.1.1,<7", + "Click>=8.1.8,<8.3", + "websocket-client>=1.8,<1.9", + "emoji>=2.14.1,<3", + "sentry-sdk>=2.20.0,<3", + "filetype>=1.2.0,<2", + "zipstream-new>=1.1.8,<1.2", + "blinker>=1.9,<2", + "zeroconf>=0.132.0,<1", + "colorlog>=6.8.2,<7", ] -if sys.platform == "darwin": - INSTALL_REQUIRES.append("appdirs>=1.4.0") - -# Additional requirements for optional install options -EXTRA_REQUIRES = dict( - # Dependencies for developing OctoPrint - develop=[ - # Testing dependencies - "mock>=2.0.0,<3", - "nose>=1.3.0,<1.4", - "ddt", - - # Documentation dependencies - "sphinx>=1.6,<1.7", - "sphinxcontrib-httpdomain", - "sphinxcontrib-mermaid>=0.3", - "sphinx_rtd_theme", - - # PyPi upload related - "pypandoc" - ], - - # Dependencies for developing OctoPrint plugins - plugins=[ - "cookiecutter>=1.4,<1.7" - ] -) - -# Additional requirements for setup -SETUP_REQUIRES = [] - -# Dependency links for any of the aforementioned dependencies -DEPENDENCY_LINKS = [] - -#----------------------------------------------------------------------------------------------------------------------- -# Anything below here is just command setup and general setup configuration - -def data_copy_build_py_factory(files, baseclass): - class data_copy_build_py(baseclass): - files = dict() - - def run(self): - import shutil - if not self.dry_run: - for directory, files in self.__class__.files.items(): - target_dir = os.path.join(self.build_lib, directory) - self.mkpath(target_dir) - - for entry in files: - if isinstance(entry, tuple): - if len(entry) != 2: - continue - source, dest = entry - else: - source = dest = entry - shutil.copy(source, os.path.join(target_dir, dest)) - - baseclass.run(self) - - return type(data_copy_build_py)(data_copy_build_py.__name__, - (data_copy_build_py,), - dict(files=files)) - -def get_cmdclass(): - cmdclass = versioneer.get_cmdclass() - - # add clean command - cmdclass.update(dict(clean=octoprint_setuptools.CleanCommand.for_options(source_folder="src", eggs=["OctoPrint*.egg-info"]))) - - # add translation commands - translation_dir = "translations" - pot_file = os.path.join(translation_dir, "messages.pot") - bundled_dir = os.path.join("src", "octoprint", "translations") - cmdclass.update(octoprint_setuptools.get_babel_commandclasses(pot_file=pot_file, output_dir=translation_dir, pack_name_prefix="OctoPrint-i18n-", pack_path_prefix="", bundled_dir=bundled_dir)) - - cmdclass["build_py"] = data_copy_build_py_factory({ - "octoprint/templates/_data": [ - "AUTHORS.md", - "CHANGELOG.md", - "SUPPORTERS.md", - "THIRDPARTYLICENSES.md", - ] - }, cmdclass["build_py"] if "build_py" in cmdclass else _build_py) - - return cmdclass - +# Development & Test dependencies +EXTRA_REQUIRES = { + "develop": [ + "pytest>=7.4.4,<8", + "pytest-doctest-custom>=1.0.0,<2", + "mock>=5.1.0,<6", + "ddt>=1.7.1,<2", + "pre-commit>=3.5.0,<5", + "nodeenv>=1.9.1,<2", + ], + "docs": [ + "sphinx>=7.2.6,<8", + "sphinx-rtd-theme>=2.0.0,<3", + "sphinxcontrib-httpdomain>=1.8.1,<2", + "sphinx-autodoc-typehints>=1.25.2,<2", + ], +} def params(): - name = "OctoPrint" - version = versioneer.get_version() - cmdclass = get_cmdclass() - - description = "A snappy web interface for 3D printers" - long_description = open("README.md").read() - - install_requires = INSTALL_REQUIRES - extras_require = EXTRA_REQUIRES - dependency_links = DEPENDENCY_LINKS - setup_requires = SETUP_REQUIRES - - try: - import pypandoc - setup_requires += ["setuptools-markdown"] - long_description_markdown_filename = "README.md" - del pypandoc - except: - pass - - classifiers = [ - "Development Status :: 4 - Beta", - "Environment :: Web Environment", - "Framework :: Flask", - "Intended Audience :: Education", - "Intended Audience :: End Users/Desktop", - "Intended Audience :: Manufacturing", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: GNU Affero General Public License v3", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python :: 2.7", - "Programming Language :: JavaScript", - "Topic :: Internet :: WWW/HTTP", - "Topic :: Internet :: WWW/HTTP :: Dynamic Content", - "Topic :: Internet :: WWW/HTTP :: WSGI", - "Topic :: Printing", - "Topic :: System :: Networking :: Monitoring" - ] - author = "Gina Häußge" - author_email = "osd@foosel.net" - url = "http://octoprint.org" - license = "AGPLv3" - - packages = find_packages(where="src") - package_dir = { - "": "src", - } - package_data = { - "octoprint": octoprint_setuptools.package_data_dirs('src/octoprint', - ['static', 'templates', 'plugins', 'translations']) - + ['util/piptestballoon/setup.py'] - } - - include_package_data = True - zip_safe = False - - if os.environ.get('READTHEDOCS', None) == 'True': - # we can't tell read the docs to please perform a pip install -e .[develop], so we help - # it a bit here by explicitly adding the development dependencies, which include our - # documentation dependencies - install_requires = install_requires + extras_require['develop'] - - entry_points = { - "console_scripts": [ - "octoprint = octoprint:main" - ] - } - - return locals() - -setup(**params()) + name = "OctoPrint" + version = versioneer.get_version() + cmdclass = versioneer.get_cmdclass() + + description = "The snappy web interface for your 3D printer (Mr Beam Fork)" + long_description = "OctoPrint provides a responsive web interface for controlling 3D printers and Laser Cutters." + + classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Flask", + "Intended Audience :: Education", + "Intended Audience :: Manufacturing", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: GNU Affero General Public License v3", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Printing", + ] + + author = "Gina Häußge / Mr Beam" + author_email = "support@mr-beam.org" + url = "https://github.com/mrbeam/mrb3-octoPrint" + license = "GNU Affero General Public License v3" + + packages = find_packages(where="src") + package_dir = {"": "src"} + + package_data = { + "octoprint": octoprint_setuptools.package_data_dirs( + "src/octoprint", ["static", "templates", "plugins", "translations"] + ) + ["util/piptestballoon/setup.py"] + } + + return dict( + name=name, + version=version, + cmdclass=cmdclass, + python_requires=PYTHON_REQUIRES, + setup_requires=SETUP_REQUIRES, + install_requires=INSTALL_REQUIRES, + extras_require=EXTRA_REQUIRES, + description=description, + long_description=long_description, + classifiers=classifiers, + author=author, + author_email=author_email, + url=url, + license=license, + packages=packages, + package_dir=package_dir, + package_data=package_data, + include_package_data=True, + zip_safe=False, + entry_points={ + "console_scripts": [ + "octoprint = octoprint:main", + ], + }, + ) + +if __name__ == "__main__": + setup(**params()) diff --git a/src/octoprint/__init__.py b/src/octoprint/__init__.py index f04a963f16..82a13f0e1b 100644 --- a/src/octoprint/__init__.py +++ b/src/octoprint/__init__.py @@ -1,24 +1,29 @@ -#!/usr/bin/env python2 -# coding=utf-8 -from __future__ import absolute_import, division, print_function +#!/usr/bin/env python +from __future__ import absolute_import, division, print_function, unicode_literals -import sys +import io import logging as log - -#~~ version +import os +import sys from ._version import get_versions + +# ~~ version + + versions = get_versions() -__version__ = versions['version'] -__branch__ = versions.get('branch', None) +__version__ = versions["version"] +__branch__ = versions.get("branch", None) __display_version__ = __version__ -__revision__ = versions.get('full-revisionid', versions.get('full', None)) +__revision__ = versions.get("full-revisionid", versions.get("full", None)) del versions del get_versions -#~~ try to ensure a sound SSL environment +# figure out current umask - sadly only doable by setting a new one and resetting it, no query method +UMASK = os.umask(0) +os.umask(UMASK) urllib3_ssl = True """Whether requests/urllib3 and urllib3 (if installed) should be able to establish @@ -26,547 +31,941 @@ version_info = sys.version_info if version_info.major == 2 and version_info.minor <= 7 and version_info.micro < 9: - try: - # make sure our requests version of urllib3 is properly patched (if possible) - import requests.packages.urllib3.contrib.pyopenssl - requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() - except ImportError: - urllib3_ssl = False - - try: - import urllib3 - - # only proceed if urllib3 is even installed on its own - try: - # urllib3 is there, let's patch that too - import urllib3.contrib.pyopenssl - urllib3.contrib.pyopenssl.inject_into_urllib3() - except ImportError: - urllib3_ssl = False - except ImportError: - pass + try: + # make sure our requests version of urllib3 is properly patched (if possible) + import requests.packages.urllib3.contrib.pyopenssl -del version_info + requests.packages.urllib3.contrib.pyopenssl.inject_into_urllib3() + except ImportError: + urllib3_ssl = False + + try: + import urllib3 + + # only proceed if urllib3 is even installed on its own + try: + # urllib3 is there, let's patch that too + import urllib3.contrib.pyopenssl + + urllib3.contrib.pyopenssl.inject_into_urllib3() + except ImportError: + urllib3_ssl = False + except ImportError: + pass + +elif version_info.major == 3 and version_info.minor >= 8 and sys.platform == "win32": + # Python 3.8 makes proactor event loop the default on Windows, Tornado doesn't like that + # + # see https://github.com/tornadoweb/tornado/issues/2608 + import asyncio -#~~ custom exceptions + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) -class FatalStartupError(BaseException): - pass - -#~~ init methods to bring up platform - -def init_platform(basedir, configfile, use_logging_file=True, logging_file=None, - logging_config=None, debug=False, verbosity=0, uncaught_logger=None, - uncaught_handler=None, safe_mode=False, ignore_blacklist=False, after_preinit_logging=None, - after_settings=None, after_logging=None, after_safe_mode=None, - after_event_manager=None, after_connectivity_checker=None, - after_plugin_manager=None, after_environment_detector=None): - kwargs = dict() - - logger, recorder = preinit_logging(debug, verbosity, uncaught_logger, uncaught_handler) - kwargs["logger"] = logger - kwargs["recorder"] = recorder - - if callable(after_preinit_logging): - after_preinit_logging(**kwargs) - - settings = init_settings(basedir, configfile) - kwargs["settings"] = settings - if callable(after_settings): - after_settings(**kwargs) - - logger = init_logging(settings, - use_logging_file=use_logging_file, - logging_file=logging_file, - default_config=logging_config, - debug=debug, - verbosity=verbosity, - uncaught_logger=uncaught_logger, - uncaught_handler=uncaught_handler) - kwargs["logger"] = logger - - if callable(after_logging): - after_logging(**kwargs) - - settings_safe_mode = settings.getBoolean(["server", "startOnceInSafeMode"]) - safe_mode = safe_mode or settings_safe_mode - kwargs["safe_mode"] = safe_mode - - if callable(after_safe_mode): - after_safe_mode(**kwargs) - - event_manager = init_event_manager(settings) - - kwargs["event_manager"] = event_manager - if callable(after_event_manager): - after_event_manager(**kwargs) - - connectivity_checker = init_connectivity_checker(settings, event_manager) - - kwargs["connectivity_checker"] = connectivity_checker - if callable(after_connectivity_checker): - after_connectivity_checker(**kwargs) - - plugin_manager = init_pluginsystem(settings, - safe_mode=safe_mode, - ignore_blacklist=ignore_blacklist, - connectivity_checker=connectivity_checker) - kwargs["plugin_manager"] = plugin_manager - - if callable(after_plugin_manager): - after_plugin_manager(**kwargs) - - environment_detector = init_environment_detector(plugin_manager) - kwargs["environment_detector"] = environment_detector - - if callable(after_environment_detector): - after_environment_detector(**kwargs) - - return settings, logger, safe_mode, event_manager, connectivity_checker, plugin_manager, environment_detector - - -def init_settings(basedir, configfile): - """Inits the settings instance based on basedir and configfile to use.""" - - from octoprint.settings import settings, InvalidSettings - try: - return settings(init=True, basedir=basedir, configfile=configfile) - except InvalidSettings as e: - message = "Error parsing the configuration file, it appears to be invalid YAML." - if e.line is not None and e.column is not None: - message += " The parser reported an error on line {}, column {}.".format(e.line, e.column) - raise FatalStartupError(message) - - -def preinit_logging(debug=False, verbosity=0, uncaught_logger=None, uncaught_handler=None): - config = { - "version": 1, - "formatters": { - "simple": { - "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - } - }, - "handlers": { - "console": { - "class": "logging.StreamHandler", - "level": "DEBUG", - "formatter": "simple", - "stream": "ext://sys.stdout" - } - }, - "loggers": { - "octoprint": { - "level": "DEBUG" if debug else "INFO" - }, - "octoprint.util": { - "level": "INFO" - } - }, - "root": { - "level": "WARN", - "handlers": ["console"] - } - } - - logger = set_logging_config(config, debug, verbosity, uncaught_logger, uncaught_handler) - - from octoprint.logging.handlers import RecordingLogHandler - recorder = RecordingLogHandler(level=log.DEBUG) - log.getLogger().addHandler(recorder) - - return logger, recorder - - -def init_logging(settings, use_logging_file=True, logging_file=None, default_config=None, debug=False, verbosity=0, uncaught_logger=None, uncaught_handler=None): - """Sets up logging.""" - - import os - - from octoprint.util import dict_merge - - # default logging configuration - if default_config is None: - default_config = { - "version": 1, - "formatters": { - "simple": { - "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - }, - "serial": { - "format": "%(asctime)s - %(message)s" - } - }, - "handlers": { - "console": { - "class": "logging.StreamHandler", - "level": "DEBUG", - "formatter": "simple", - "stream": "ext://sys.stdout" - }, - "file": { - "class": "octoprint.logging.handlers.OctoPrintLogHandler", - "level": "DEBUG", - "formatter": "simple", - "when": "D", - "backupCount": 6, - "filename": os.path.join(settings.getBaseFolder("logs"), "octoprint.log") - }, - "serialFile": { - "class": "octoprint.logging.handlers.SerialLogHandler", - "level": "DEBUG", - "formatter": "serial", - "backupCount": 3, - "filename": os.path.join(settings.getBaseFolder("logs"), "serial.log"), - "delay": True - } - }, - "loggers": { - "SERIAL": { - "level": "INFO", - "handlers": ["serialFile"], - "propagate": False - }, - "octoprint": { - "level": "INFO" - }, - "octoprint.util": { - "level": "INFO" - }, - "octoprint.plugins": { - "level": "INFO" - } - }, - "root": { - "level": "WARN", - "handlers": ["console", "file"] - } - } - - if debug or verbosity > 0: - default_config["loggers"]["octoprint"]["level"] = "DEBUG" - default_config["root"]["level"] = "INFO" - if verbosity > 1: - default_config["loggers"]["octoprint.plugins"]["level"] = "DEBUG" - if verbosity > 2: - default_config["root"]["level"] = "DEBUG" - - config = default_config - if use_logging_file: - # further logging configuration from file... - if logging_file is None: - logging_file = os.path.join(settings.getBaseFolder("base"), "logging.yaml") - - config_from_file = {} - if os.path.exists(logging_file) and os.path.isfile(logging_file): - import yaml - with open(logging_file, "r") as f: - config_from_file = yaml.safe_load(f) - - # we merge that with the default config - if config_from_file is not None and isinstance(config_from_file, dict): - config = dict_merge(default_config, config_from_file) - - # configure logging globally - return set_logging_config(config, debug, verbosity, uncaught_logger, uncaught_handler) +del version_info + +# ~~ custom exceptions + + +class FatalStartupError(Exception): + def __init__(self, message, cause=None): + self.cause = cause + Exception.__init__(self, message) + + def __str__(self): + result = Exception.__str__(self) + if self.cause: + return "{}: {}".format(result, str(self.cause)) + else: + return result + + +# ~~ init methods to bring up platform + + +def init_platform( + basedir, + configfile, + overlays=None, + use_logging_file=True, + logging_file=None, + logging_config=None, + debug=False, + verbosity=0, + uncaught_logger=None, + uncaught_handler=None, + safe_mode=False, + ignore_blacklist=False, + after_preinit_logging=None, + after_settings_init=None, + after_logging=None, + after_safe_mode=None, + after_settings_valid=None, + after_event_manager=None, + after_connectivity_checker=None, + after_plugin_manager=None, + after_environment_detector=None, +): + kwargs = {} + + logger, recorder = preinit_logging( + debug, verbosity, uncaught_logger, uncaught_handler + ) + kwargs["logger"] = logger + kwargs["recorder"] = recorder + + if callable(after_preinit_logging): + after_preinit_logging(**kwargs) + + try: + settings = init_settings(basedir, configfile, overlays=overlays) + except Exception as ex: + raise FatalStartupError("Could not initialize settings manager", cause=ex) + kwargs["settings"] = settings + if callable(after_settings_init): + after_settings_init(**kwargs) + + try: + logger = init_logging( + settings, + use_logging_file=use_logging_file, + logging_file=logging_file, + default_config=logging_config, + debug=debug, + verbosity=verbosity, + uncaught_logger=uncaught_logger, + uncaught_handler=uncaught_handler, + ) + except Exception as ex: + raise FatalStartupError("Could not initialize logging", cause=ex) + + kwargs["logger"] = logger + if callable(after_logging): + after_logging(**kwargs) + + settings_start_once_in_safemode = ( + "settings" if settings.getBoolean(["server", "startOnceInSafeMode"]) else None + ) + settings_incomplete_startup_safemode = ( + "incomplete_startup" + if settings.getBoolean(["server", "incompleteStartup"]) + and not settings.getBoolean(["server", "ignoreIncompleteStartup"]) + else None + ) + safe_mode = ( + safe_mode + or settings_start_once_in_safemode + or settings_incomplete_startup_safemode + ) + kwargs["safe_mode"] = safe_mode + if callable(after_safe_mode): + after_safe_mode(**kwargs) + + # now before we continue, let's make sure *all* our folders are sane + try: + settings.sanity_check_folders() + except Exception as ex: + raise FatalStartupError("Configured folders didn't pass sanity check", cause=ex) + if callable(after_settings_valid): + after_settings_valid(**kwargs) + + try: + event_manager = init_event_manager(settings) + except Exception as ex: + raise FatalStartupError("Could not initialize event manager", cause=ex) + + kwargs["event_manager"] = event_manager + if callable(after_event_manager): + after_event_manager(**kwargs) + + try: + connectivity_checker = init_connectivity_checker(settings, event_manager) + except Exception as ex: + raise FatalStartupError("Could not initialize connectivity checker", cause=ex) + + kwargs["connectivity_checker"] = connectivity_checker + if callable(after_connectivity_checker): + after_connectivity_checker(**kwargs) + + try: + plugin_manager = init_pluginsystem( + settings, + safe_mode=safe_mode, + ignore_blacklist=ignore_blacklist, + connectivity_checker=connectivity_checker, + ) + except Exception as ex: + raise FatalStartupError("Could not initialize plugin manager", cause=ex) + + kwargs["plugin_manager"] = plugin_manager + if callable(after_plugin_manager): + after_plugin_manager(**kwargs) + + try: + environment_detector = init_environment_detector(plugin_manager) + except Exception as ex: + raise FatalStartupError("Could not initialize environment detector", cause=ex) + + kwargs["environment_detector"] = environment_detector + if callable(after_environment_detector): + after_environment_detector(**kwargs) + + return ( + settings, + logger, + safe_mode, + event_manager, + connectivity_checker, + plugin_manager, + environment_detector, + ) + + +def init_settings(basedir, configfile, overlays=None): + """Inits the settings instance based on basedir and configfile to use.""" + + from octoprint.settings import InvalidSettings, settings + + try: + return settings( + init=True, basedir=basedir, configfile=configfile, overlays=overlays + ) + except InvalidSettings as e: + raise FatalStartupError(str(e)) + + +def preinit_logging( + debug=False, verbosity=0, uncaught_logger=None, uncaught_handler=None +): + config = { + "version": 1, + "formatters": { + "simple": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"} + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "level": "DEBUG", + "formatter": "simple", + "stream": "ext://sys.stdout", + } + }, + "loggers": { + "octoprint": {"level": "DEBUG" if debug else "INFO"}, + "octoprint.util": {"level": "INFO"}, + }, + "root": {"level": "WARN", "handlers": ["console"]}, + } + + logger = set_logging_config( + config, debug, verbosity, uncaught_logger, uncaught_handler + ) + + from octoprint.logging.handlers import RecordingLogHandler + + recorder = RecordingLogHandler(level=log.DEBUG) + log.getLogger().addHandler(recorder) + + return logger, recorder + + +def init_logging( + settings, + use_logging_file=True, + logging_file=None, + default_config=None, + debug=False, + verbosity=0, + uncaught_logger=None, + uncaught_handler=None, +): + """Sets up logging.""" + + import os + + from octoprint.util import dict_merge + + # default logging configuration + if default_config is None: + simple_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + default_config = { + "version": 1, + "formatters": { + "simple": {"format": simple_format}, + "colored": { + "()": "colorlog.ColoredFormatter", + "format": "%(log_color)s" + simple_format + "%(reset)s", + "reset": True, + "log_colors": { + "DEBUG": "cyan", + "INFO": "white", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "bold_red", + }, + }, + "serial": {"format": "%(asctime)s - %(message)s"}, + "timings": {"format": "%(asctime)s - %(message)s"}, + "timingscsv": {"format": "%(asctime)s;%(func)s;%(timing)f"}, + }, + "handlers": { + "console": { + "class": "octoprint.logging.handlers.OctoPrintStreamHandler", + "level": "DEBUG", + "formatter": "colored", + "stream": "ext://sys.stdout", + }, + "file": { + "class": "octoprint.logging.handlers.OctoPrintLogHandler", + "level": "DEBUG", + "formatter": "simple", + "when": "D", + "backupCount": 6, + "filename": os.path.join( + settings.getBaseFolder("logs"), "octoprint.log" + ), + }, + "serialFile": { + "class": "octoprint.logging.handlers.SerialLogHandler", + "level": "DEBUG", + "formatter": "serial", + "backupCount": 3, + "filename": os.path.join( + settings.getBaseFolder("logs"), "serial.log" + ), + "delay": True, + }, + "pluginTimingsFile": { + "class": "octoprint.logging.handlers.PluginTimingsLogHandler", + "level": "DEBUG", + "formatter": "timings", + "backupCount": 3, + "filename": os.path.join( + settings.getBaseFolder("logs"), "plugintimings.log" + ), + "delay": True, + }, + "pluginTimingsCsvFile": { + "class": "octoprint.logging.handlers.PluginTimingsLogHandler", + "level": "DEBUG", + "formatter": "timingscsv", + "backupCount": 3, + "filename": os.path.join( + settings.getBaseFolder("logs"), "plugintimings.csv" + ), + "delay": True, + }, + }, + "loggers": { + "SERIAL": { + "level": "INFO", + "handlers": ["serialFile"], + "propagate": False, + }, + "PLUGIN_TIMINGS": { + "level": "INFO", + "handlers": ["pluginTimingsFile", "pluginTimingsCsvFile"], + "propagate": False, + }, + "PLUGIN_TIMINGS.octoprint.plugin": {"level": "INFO"}, + "octoprint": {"level": "INFO"}, + "octoprint.util": {"level": "INFO"}, + "octoprint.plugins": {"level": "INFO"}, + }, + "root": {"level": "WARN", "handlers": ["console", "file"]}, + } + + if debug or verbosity > 0: + default_config["loggers"]["octoprint"]["level"] = "DEBUG" + default_config["root"]["level"] = "INFO" + if verbosity > 1: + default_config["loggers"]["octoprint.plugins"]["level"] = "DEBUG" + if verbosity > 2: + default_config["root"]["level"] = "DEBUG" + + config = default_config + if use_logging_file: + # further logging configuration from file... + if logging_file is None: + logging_file = os.path.join(settings.getBaseFolder("base"), "logging.yaml") + + config_from_file = {} + if os.path.exists(logging_file) and os.path.isfile(logging_file): + import yaml + + with io.open(logging_file, "rt", encoding="utf-8") as f: + config_from_file = yaml.safe_load(f) + + # we merge that with the default config + if config_from_file is not None and isinstance(config_from_file, dict): + config = dict_merge(default_config, config_from_file) + + # configure logging globally + return set_logging_config(config, debug, verbosity, uncaught_logger, uncaught_handler) + + +def octoprint_plugin_inject_factory(settings, components): + import octoprint.plugin + + def f(name, implementation): + """Factory for injections for all OctoPrintPlugins""" + if not isinstance(implementation, octoprint.plugin.OctoPrintPlugin): + return None + + components_copy = dict(components) + if "printer" in components: + import functools + + import wrapt + + def tagwrap(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + tags = kwargs.get("tags", set()) | { + "source:plugin", + "plugin:{}".format(name), + } + kwargs["tags"] = tags + return f(*args, **kwargs) + + wrapper.__tagwrapped__ = True + return wrapper + + class TaggedFuncsPrinter(wrapt.ObjectProxy): + def __getattribute__(self, attr): + __wrapped__ = super(TaggedFuncsPrinter, self).__getattribute__( + "__wrapped__" + ) + if attr == "__wrapped__": + return __wrapped__ + + item = getattr(__wrapped__, attr) + if ( + callable(item) + and ( + "tags" in item.__code__.co_varnames + or "kwargs" in item.__code__.co_varnames + ) + and not getattr(item, "__tagwrapped__", False) + ): + return tagwrap(item) + else: + return item + + components_copy["printer"] = TaggedFuncsPrinter(components["printer"]) + + props = {} + props.update(components_copy) + props.update({"data_folder": os.path.join(settings.getBaseFolder("data"), name)}) + return props + + return f + + +def settings_plugin_inject_factory(settings): + import octoprint.plugin + + def f(name, implementation): + """Factory for additional injections/initializations depending on plugin type""" + if not isinstance(implementation, octoprint.plugin.SettingsPlugin): + return + + default_settings_overlay = {"plugins": {}} + default_settings_overlay["plugins"][name] = implementation.get_settings_defaults() + settings.add_overlay(default_settings_overlay, at_end=True) + + plugin_settings = octoprint.plugin.plugin_settings_for_settings_plugin( + name, implementation + ) + if plugin_settings is None: + return + + return {"settings": plugin_settings} + + return f + + +def init_settings_plugin_config_migration_and_cleanup(plugin_manager): + import logging + + import octoprint.plugin + + def settings_plugin_config_migration_and_cleanup(identifier, implementation): + """Take care of migrating and cleaning up any old settings""" + + if not isinstance(implementation, octoprint.plugin.SettingsPlugin): + return + + settings_version = implementation.get_settings_version() + settings_migrator = implementation.on_settings_migrate + + if settings_version is not None and settings_migrator is not None: + stored_version = implementation._settings.get_int( + [octoprint.plugin.SettingsPlugin.config_version_key] + ) + if stored_version is None or stored_version < settings_version: + settings_migrator(settings_version, stored_version) + implementation._settings.set_int( + [octoprint.plugin.SettingsPlugin.config_version_key], + settings_version, + force=True, + ) + + implementation.on_settings_cleanup() + implementation._settings.save() + + implementation.on_settings_initialized() + + settingsPlugins = plugin_manager.get_implementations(octoprint.plugin.SettingsPlugin) + for implementation in settingsPlugins: + try: + settings_plugin_config_migration_and_cleanup( + implementation._identifier, implementation + ) + except Exception: + logging.getLogger(__name__).exception( + "Error while trying to migrate settings for " + "plugin {}, ignoring it".format(implementation._identifier), + extra={"plugin": implementation._identifier}, + ) + + plugin_manager.implementation_post_inits = [ + settings_plugin_config_migration_and_cleanup + ] + + +def init_custom_events(plugin_manager): + import logging + + import octoprint.events + + logger = logging.getLogger(__name__) + + custom_events_hooks = plugin_manager.get_hooks( + "octoprint.events.register_custom_events" + ) + for name, hook in custom_events_hooks.items(): + try: + result = hook() + if isinstance(result, (list, tuple)): + for event in result: + constant, value = octoprint.events.Events.register_event( + event, prefix="plugin_{}_".format(name) + ) + logger.debug( + 'Registered event {} of plugin {} as Events.{} = "{}"'.format( + event, name, constant, value + ) + ) + except Exception: + logger.exception( + "Error while retrieving custom event list from plugin {}".format(name), + extra={"plugin": name}, + ) def set_logging_config(config, debug, verbosity, uncaught_logger, uncaught_handler): - # configure logging globally - import logging.config as logconfig - logconfig.dictConfig(config) - - # make sure we log any warnings - log.captureWarnings(True) - - import warnings - - categories = (DeprecationWarning, PendingDeprecationWarning) - if verbosity > 2: - warnings.simplefilter("always") - elif debug or verbosity > 0: - for category in categories: - warnings.simplefilter("always", category=category) - - # make sure we also log any uncaught exceptions - if uncaught_logger is None: - logger = log.getLogger(__name__) - else: - logger = log.getLogger(uncaught_logger) - - if uncaught_handler is None: - def exception_logger(exc_type, exc_value, exc_tb): - logger.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_tb)) - - uncaught_handler = exception_logger - sys.excepthook = uncaught_handler - - return logger - - -def init_pluginsystem(settings, safe_mode=False, ignore_blacklist=True, connectivity_checker=None): - """Initializes the plugin manager based on the settings.""" - - import os - - logger = log.getLogger(__name__ + ".startup") - - plugin_folders = [(os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), "plugins")), True), - settings.getBaseFolder("plugins")] - plugin_entry_points = ["octoprint.plugin"] - plugin_disabled_list = settings.get(["plugins", "_disabled"]) - - plugin_blacklist = [] - if not ignore_blacklist and settings.getBoolean(["server", "pluginBlacklist", "enabled"]): - plugin_blacklist = get_plugin_blacklist(settings, connectivity_checker=connectivity_checker) - - plugin_validators = [] - if safe_mode: - def validator(phase, plugin_info): - if phase == "after_load": - setattr(plugin_info, "safe_mode_victim", not plugin_info.bundled) - setattr(plugin_info, "safe_mode_enabled", False) - elif phase == "before_enable": - if not plugin_info.bundled: - setattr(plugin_info, "safe_mode_enabled", True) - return False - return True - plugin_validators.append(validator) - - from octoprint.plugin import plugin_manager - pm = plugin_manager(init=True, - plugin_folders=plugin_folders, - plugin_entry_points=plugin_entry_points, - plugin_disabled_list=plugin_disabled_list, - plugin_blacklist=plugin_blacklist, - plugin_validators=plugin_validators) - - settings_overlays = dict() - disabled_from_overlays = dict() - - def handle_plugin_loaded(name, plugin): - if plugin.instance and hasattr(plugin.instance, "__plugin_settings_overlay__"): - plugin.needs_restart = True - - # plugin has a settings overlay, inject it - overlay_definition = getattr(plugin.instance, "__plugin_settings_overlay__") - if isinstance(overlay_definition, (tuple, list)): - overlay_definition, order = overlay_definition - else: - order = None - - overlay = settings.load_overlay(overlay_definition) - - if "plugins" in overlay and "_disabled" in overlay["plugins"]: - disabled_plugins = overlay["plugins"]["_disabled"] - del overlay["plugins"]["_disabled"] - disabled_from_overlays[name] = (disabled_plugins, order) - - settings_overlays[name] = overlay - logger.debug("Found settings overlay on plugin {}".format(name)) - - def handle_plugins_loaded(startup=False, initialize_implementations=True, force_reload=None): - if not startup: - return - - sorted_disabled_from_overlays = sorted([(key, value[0], value[1]) for key, value in disabled_from_overlays.items()], key=lambda x: (x[2] is None, x[2], x[0])) - - disabled_list = pm.plugin_disabled_list - already_processed = [] - for name, addons, _ in sorted_disabled_from_overlays: - if not name in disabled_list and not name.endswith("disabled"): - for addon in addons: - if addon in disabled_list: - continue - - if addon in already_processed: - logger.info("Plugin {} wants to disable plugin {}, but that was already processed".format(name, addon)) - - if not addon in already_processed and not addon in disabled_list: - disabled_list.append(addon) - logger.info("Disabling plugin {} as defined by plugin {}".format(addon, name)) - already_processed.append(name) - - def handle_plugin_enabled(name, plugin): - if name in settings_overlays: - settings.add_overlay(settings_overlays[name]) - logger.info("Added settings overlay from plugin {}".format(name)) - - pm.on_plugin_loaded = handle_plugin_loaded - pm.on_plugins_loaded = handle_plugins_loaded - pm.on_plugin_enabled = handle_plugin_enabled - pm.reload_plugins(startup=True, initialize_implementations=False) - return pm + # configure logging globally + import logging.config as logconfig + + from octoprint.logging.filters import TornadoAccessFilter + + logconfig.dictConfig(config) + + # make sure we log any warnings + log.captureWarnings(True) + + import warnings + + categories = (DeprecationWarning, PendingDeprecationWarning) + if verbosity > 2: + warnings.simplefilter("always") + elif debug or verbosity > 0: + for category in categories: + warnings.simplefilter("always", category=category) + + # make sure we also log any uncaught exceptions + if uncaught_logger is None: + logger = log.getLogger(__name__) + else: + logger = log.getLogger(uncaught_logger) + + if uncaught_handler is None: + + def exception_logger(exc_type, exc_value, exc_tb): + logger.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_tb)) + + uncaught_handler = exception_logger + sys.excepthook = uncaught_handler + + tornado_logger = log.getLogger("tornado.access") + tornado_logger.addFilter(TornadoAccessFilter()) + + return logger + + +def init_pluginsystem( + settings, safe_mode=False, ignore_blacklist=True, connectivity_checker=None +): + """Initializes the plugin manager based on the settings.""" + + import os + + # we need this so that octoprint.plugins is in sys.modules and no warnings are caused when loading bundled plugins + import octoprint.plugins # noqa: F401 + + logger = log.getLogger(__name__ + ".startup") + + plugin_folders = [ + ( + os.path.abspath( + os.path.join(os.path.dirname(os.path.realpath(__file__)), "plugins") + ), + "octoprint.plugins", + True, + ), + settings.getBaseFolder("plugins"), + ] + plugin_entry_points = ["octoprint.plugin"] + plugin_disabled_list = settings.get(["plugins", "_disabled"]) + + plugin_blacklist = [] + if not ignore_blacklist and settings.getBoolean( + ["server", "pluginBlacklist", "enabled"] + ): + plugin_blacklist = get_plugin_blacklist( + settings, connectivity_checker=connectivity_checker + ) + + plugin_validators = [] + + if safe_mode: + + def safe_mode_validator(phase, plugin_info): + if phase in ("before_import", "before_load", "before_enable"): + plugin_info.safe_mode_victim = not plugin_info.bundled + if not plugin_info.bundled: + return False + return True + + plugin_validators.append(safe_mode_validator) + + compatibility_ignored_list = settings.get(["plugins", "_forcedCompatible"]) + + from octoprint.plugin import plugin_manager + + pm = plugin_manager( + init=True, + plugin_folders=plugin_folders, + plugin_entry_points=plugin_entry_points, + plugin_disabled_list=plugin_disabled_list, + plugin_blacklist=plugin_blacklist, + plugin_validators=plugin_validators, + compatibility_ignored_list=compatibility_ignored_list, + ) + + settings_overlays = {} + disabled_from_overlays = {} + + def handle_plugin_loaded(name, plugin): + if plugin.instance and hasattr(plugin.instance, "__plugin_settings_overlay__"): + plugin.needs_restart = True + + # plugin has a settings overlay, inject it + overlay_definition = plugin.instance.__plugin_settings_overlay__ + if isinstance(overlay_definition, (tuple, list)): + overlay_definition, order = overlay_definition + else: + order = None + + overlay = settings.load_overlay(overlay_definition) + + if "plugins" in overlay and "_disabled" in overlay["plugins"]: + disabled_plugins = overlay["plugins"]["_disabled"] + del overlay["plugins"]["_disabled"] + disabled_from_overlays[name] = (disabled_plugins, order) + + settings_overlays[name] = overlay + logger.debug("Found settings overlay on plugin {}".format(name)) + + def handle_plugins_loaded( + startup=False, initialize_implementations=True, force_reload=None + ): + if not startup: + return + + from octoprint.util import sv + + sorted_disabled_from_overlays = sorted( + [(key, value[0], value[1]) for key, value in disabled_from_overlays.items()], + key=lambda x: (x[2] is None, sv(x[2]), sv(x[0])), + ) + + disabled_list = pm.plugin_disabled_list + already_processed = [] + for name, addons, _ in sorted_disabled_from_overlays: + if name not in disabled_list and not name.endswith("disabled"): + for addon in addons: + if addon in disabled_list: + continue + + if addon in already_processed: + logger.info( + "Plugin {} wants to disable plugin {}, but that was already processed".format( + name, addon + ) + ) + + if addon not in already_processed and addon not in disabled_list: + disabled_list.append(addon) + logger.info( + "Disabling plugin {} as defined by plugin {}".format( + addon, name + ) + ) + already_processed.append(name) + + def handle_plugin_enabled(name, plugin): + if name in settings_overlays: + settings.add_overlay(settings_overlays[name]) + logger.info("Added settings overlay from plugin {}".format(name)) + + pm.on_plugin_loaded = handle_plugin_loaded + pm.on_plugins_loaded = handle_plugins_loaded + pm.on_plugin_enabled = handle_plugin_enabled + pm.reload_plugins(startup=True, initialize_implementations=False) + return pm def get_plugin_blacklist(settings, connectivity_checker=None): - import requests - import os - import time - import yaml - - from octoprint.util import bom_aware_open - from octoprint.util.version import is_octoprint_compatible - - logger = log.getLogger(__name__ + ".startup") - - if connectivity_checker is not None and not connectivity_checker.online: - logger.info("We don't appear to be online, not fetching plugin blacklist") - return [] - - def format_blacklist(entries): - format_entry = lambda x: "{} ({})".format(x[0], x[1]) if isinstance(x, (list, tuple)) and len(x) == 2 \ - else "{} (any)".format(x) - return ", ".join(map(format_entry, entries)) - - def process_blacklist(entries): - result = [] - - if not isinstance(entries, list): - return result - - for entry in entries: - if not "plugin" in entry: - continue - - if "octoversions" in entry and not is_octoprint_compatible(*entry["octoversions"]): - continue - - if "version" in entry: - logger.debug("Blacklisted plugin: {}, version: {}".format(entry["plugin"], entry["version"])) - result.append((entry["plugin"], entry["version"])) - elif "versions" in entry: - logger.debug("Blacklisted plugin: {}, versions: {}".format(entry["plugin"], ", ".join(entry["versions"]))) - for version in entry["versions"]: - result.append((entry["plugin"], version)) - else: - logger.debug("Blacklisted plugin: {}".format(entry["plugin"])) - result.append(entry["key"]) - - return result - - def fetch_blacklist_from_cache(path, ttl): - if not os.path.isfile(path): - return None - - if os.stat(path).st_mtime + ttl < time.time(): - return None - - with bom_aware_open(path, encoding="utf-8", mode="r") as f: - result = yaml.safe_load(f) - - if isinstance(result, list): - return result - - def fetch_blacklist_from_url(url, timeout=3, cache=None): - result = [] - try: - r = requests.get(url, timeout=timeout) - result = process_blacklist(r.json()) - - if cache is not None: - try: - with bom_aware_open(cache, encoding="utf-8", mode="w") as f: - yaml.safe_dump(result, f) - except: - logger.info("Fetched plugin blacklist but couldn't write it to its cache file.") - except: - logger.info("Unable to fetch plugin blacklist from {}, proceeding without it.".format(url)) - return result - - try: - # first attempt to fetch from cache - cache_path = os.path.join(settings.getBaseFolder("data"), "plugin_blacklist.yaml") - ttl = settings.getInt(["server", "pluginBlacklist", "ttl"]) - blacklist = fetch_blacklist_from_cache(cache_path, ttl) - - if blacklist is None: - # no result from the cache, let's fetch it fresh - url = settings.get(["server", "pluginBlacklist", "url"]) - timeout = settings.getFloat(["server", "pluginBlacklist", "timeout"]) - blacklist = fetch_blacklist_from_url(url, timeout=timeout, cache=cache_path) - - if blacklist is None: - # still now result, so no blacklist - blacklist = [] - - if blacklist: - logger.info("Blacklist processing done, " - "adding {} blacklisted plugin versions: {}".format(len(blacklist), - format_blacklist(blacklist))) - else: - logger.info("Blacklist processing done") - - return blacklist - except: - logger.exception("Something went wrong while processing the plugin blacklist. Proceeding without it.") + import os + import time + + import requests + import yaml + + from octoprint.util import bom_aware_open + from octoprint.util.version import is_octoprint_compatible, is_python_compatible + + logger = log.getLogger(__name__ + ".startup") + + if connectivity_checker is not None and not connectivity_checker.online: + logger.info("We don't appear to be online, not fetching plugin blacklist") + return [] + + def format_blacklist(entries): + format_entry = ( + lambda x: "{} ({})".format(x[0], x[1]) + if isinstance(x, (list, tuple)) and len(x) == 2 + else "{} (any)".format(x) + ) + return ", ".join(map(format_entry, entries)) + + def process_blacklist(entries): + result = [] + + if not isinstance(entries, list): + return result + + for entry in entries: + if "plugin" not in entry: + continue + + if "octoversions" in entry and not is_octoprint_compatible( + *entry["octoversions"] + ): + continue + + if "pythonversions" in entry and not is_python_compatible( + *entry["pythonversions"] + ): + continue + + if "pluginversions" in entry: + logger.debug( + "Blacklisted plugin: {}, versions: {}".format( + entry["plugin"], ", ".join(entry["pluginversions"]) + ) + ) + for version in entry["pluginversions"]: + result.append((entry["plugin"], version)) + elif "versions" in entry: + logger.debug( + "Blacklisted plugin: {}, versions: {}".format( + entry["plugin"], ", ".join(entry["versions"]) + ) + ) + for version in entry["versions"]: + result.append((entry["plugin"], "=={}".format(version))) + else: + logger.debug("Blacklisted plugin: {}".format(entry["plugin"])) + result.append(entry["plugin"]) + + return result + + def fetch_blacklist_from_cache(path, ttl): + if not os.path.isfile(path): + return None + + if os.stat(path).st_mtime + ttl < time.time(): + return None + + with bom_aware_open(path, encoding="utf-8", mode="rt") as f: + result = yaml.safe_load(f) + + if isinstance(result, list): + return result + + def fetch_blacklist_from_url(url, timeout=3, cache=None): + result = [] + try: + r = requests.get(url, timeout=timeout) + result = process_blacklist(r.json()) + + if cache is not None: + try: + with bom_aware_open(cache, encoding="utf-8", mode="wt") as f: + yaml.safe_dump(result, f) + except Exception as e: + logger.info( + "Fetched plugin blacklist but couldn't write it to its cache file: %s", + e, + ) + except Exception as e: + logger.info( + "Unable to fetch plugin blacklist from {}, proceeding without it: {}".format( + url, e + ) + ) + return result + + try: + # first attempt to fetch from cache + cache_path = os.path.join(settings.getBaseFolder("data"), "plugin_blacklist.yaml") + ttl = settings.getInt(["server", "pluginBlacklist", "ttl"]) + blacklist = fetch_blacklist_from_cache(cache_path, ttl) + + if blacklist is None: + # no result from the cache, let's fetch it fresh + url = settings.get(["server", "pluginBlacklist", "url"]) + timeout = settings.getFloat(["server", "pluginBlacklist", "timeout"]) + blacklist = fetch_blacklist_from_url(url, timeout=timeout, cache=cache_path) + + if blacklist is None: + # still now result, so no blacklist + blacklist = [] + + if blacklist: + logger.info( + "Blacklist processing done, " + "adding {} blacklisted plugin versions: {}".format( + len(blacklist), format_blacklist(blacklist) + ) + ) + else: + logger.info("Blacklist processing done") + + return blacklist + except Exception: + logger.exception( + "Something went wrong while processing the plugin blacklist. Proceeding without it." + ) def init_event_manager(settings): - from octoprint.events import eventManager - return eventManager() + from octoprint.events import eventManager + return eventManager() -def init_connectivity_checker(settings, event_manager): - from octoprint.events import Events - from octoprint.util import ConnectivityChecker - # start regular check if we are connected to the internet - connectivityEnabled = settings.getBoolean(["server", "onlineCheck", "enabled"]) - connectivityInterval = settings.getInt(["server", "onlineCheck", "interval"]) - connectivityHost = settings.get(["server", "onlineCheck", "host"]) - connectivityPort = settings.getInt(["server", "onlineCheck", "port"]) +def init_connectivity_checker(settings, event_manager): + from octoprint.events import Events + from octoprint.util import ConnectivityChecker + + # start regular check if we are connected to the internet + connectivityEnabled = settings.getBoolean(["server", "onlineCheck", "enabled"]) + connectivityInterval = settings.getInt(["server", "onlineCheck", "interval"]) + connectivityHost = settings.get(["server", "onlineCheck", "host"]) + connectivityPort = settings.getInt(["server", "onlineCheck", "port"]) + connectivityName = settings.get(["server", "onlineCheck", "name"]) + + def on_connectivity_change( + old_value, new_value, connection_working=None, resolution_working=None + ): + event_manager.fire( + Events.CONNECTIVITY_CHANGED, + payload={ + "old": old_value, + "new": new_value, + "connection": connection_working, + "resolution": resolution_working, + }, + ) + + connectivityChecker = ConnectivityChecker( + connectivityInterval, + connectivityHost, + port=connectivityPort, + name=connectivityName, + enabled=connectivityEnabled, + on_change=on_connectivity_change, + ) + connectivityChecker.check_immediately() + connectivityChecker.log_full_report() + + return connectivityChecker - def on_connectivity_change(old_value, new_value): - event_manager.fire(Events.CONNECTIVITY_CHANGED, payload=dict(old=old_value, new=new_value)) - connectivityChecker = ConnectivityChecker(connectivityInterval, - connectivityHost, - port=connectivityPort, - enabled=connectivityEnabled, - on_change=on_connectivity_change) +def init_environment_detector(plugin_manager): + from octoprint.environment import EnvironmentDetector - return connectivityChecker + return EnvironmentDetector(plugin_manager) -def init_environment_detector(plugin_manager): - from octoprint.environment import EnvironmentDetector - return EnvironmentDetector(plugin_manager) +# ~~ server main method -#~~ server main method def main(): - import sys - - # os args are gained differently on win32 - try: - from click.utils import get_os_args - args = get_os_args() - except ImportError: - # for whatever reason we are running an older Click version? - args = sys.argv[1:] - - if len(args) >= len(sys.argv): - # Now some ugly preprocessing of our arguments starts. We have a somewhat difficult situation on our hands - # here if we are running under Windows and want to be able to handle utf-8 command line parameters (think - # plugin parameters such as names or something, e.g. for the "dev plugin:new" command) while at the same - # time also supporting sys.argv rewriting for debuggers etc (e.g. PyCharm). - # - # So what we try to do here is solve this... Generally speaking, sys.argv and whatever Windows returns - # for its CommandLineToArgvW win32 function should have the same length. If it doesn't however and - # sys.argv is shorter than the win32 specific command line arguments, obviously stuff was cut off from - # sys.argv which also needs to be cut off of the win32 command line arguments. - # - # So this is what we do here. - - # -1 because first entry is the script that was called - sys_args_length = len(sys.argv) - 1 - - # cut off stuff from the beginning - args = args[-1 * sys_args_length:] if sys_args_length else [] - - from octoprint.cli import octo - octo(args=args, prog_name="octoprint", auto_envvar_prefix="OCTOPRINT") + import sys + + # os args are gained differently on win32 + try: + from click.utils import get_os_args + + args = get_os_args() + except ImportError: + # for whatever reason we are running an older Click version? + args = sys.argv[1:] + + if len(args) >= len(sys.argv): + # Now some ugly preprocessing of our arguments starts. We have a somewhat difficult situation on our hands + # here if we are running under Windows and want to be able to handle utf-8 command line parameters (think + # plugin parameters such as names or something, e.g. for the "dev plugin:new" command) while at the same + # time also supporting sys.argv rewriting for debuggers etc (e.g. PyCharm). + # + # So what we try to do here is solve this... Generally speaking, sys.argv and whatever Windows returns + # for its CommandLineToArgvW win32 function should have the same length. If it doesn't however and + # sys.argv is shorter than the win32 specific command line arguments, obviously stuff was cut off from + # sys.argv which also needs to be cut off of the win32 command line arguments. + # + # So this is what we do here. + + # -1 because first entry is the script that was called + sys_args_length = len(sys.argv) - 1 + + # cut off stuff from the beginning + args = args[-1 * sys_args_length :] if sys_args_length else [] + + from octoprint.util.fixes import patch_sarge_async_on_py2 + + patch_sarge_async_on_py2() + + from octoprint.cli import octo + + octo(args=args, prog_name="octoprint", auto_envvar_prefix="OCTOPRINT") if __name__ == "__main__": - main() + main() + +from . import _version +__version__ = _version.get_versions()['version'] diff --git a/src/octoprint/__main__.py b/src/octoprint/__main__.py index 3dd81998d9..b485bf466e 100644 --- a/src/octoprint/__main__.py +++ b/src/octoprint/__main__.py @@ -1,7 +1,7 @@ -#!/usr/bin/env python2 -# coding=utf-8 -from __future__ import absolute_import, division, print_function +#!/usr/bin/env python +from __future__ import absolute_import, division, print_function, unicode_literals if __name__ == "__main__": - import octoprint - octoprint.main() + import octoprint + + octoprint.main() diff --git a/src/octoprint/_version.py b/src/octoprint/_version.py index ee94065844..b6e4d1f30c 100644 --- a/src/octoprint/_version.py +++ b/src/octoprint/_version.py @@ -5,20 +5,22 @@ # directories (produced by setup.py build) will contain a much shorter file # that just contains the computed version number. -# This file is released into the public domain. Generated by -# versioneer-0.15+dev (https://github.com/warner/python-versioneer) +# This file is released into the public domain. +# Generated by versioneer-0.29 +# https://github.com/python-versioneer/python-versioneer """Git implementation of _version.py.""" -from __future__ import absolute_import, division, print_function import errno import os import re import subprocess import sys +from typing import Any, Callable, Dict, List, Optional, Tuple +import functools -def get_keywords(): +def get_keywords() -> Dict[str, str]: """Get the keywords needed to look up the version information.""" # these strings will be replaced by git during git-archive. # setup.py/versioneer.py will grep for the variable names, so they must @@ -26,42 +28,47 @@ def get_keywords(): # get_keywords(). git_refnames = "$Format:%d$" git_full = "$Format:%H$" - keywords = {"refnames": git_refnames, "full": git_full} + git_date = "$Format:%ci$" + keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} return keywords class VersioneerConfig: - """Container for Versioneer configuration parameters.""" + VCS: str + style: str + tag_prefix: str + parentdir_prefix: str + versionfile_source: str + verbose: bool + -def get_config(): +def get_config() -> VersioneerConfig: """Create, populate and return the VersioneerConfig() object.""" # these strings are filled in when 'setup.py versioneer' creates # _version.py cfg = VersioneerConfig() cfg.VCS = "git" - cfg.style = "pep440-post" - cfg.tag_prefix = "" + cfg.style = "pep440" + cfg.tag_prefix = "v" cfg.parentdir_prefix = "" cfg.versionfile_source = "src/octoprint/_version.py" - cfg.lookupfile = ".versioneer-lookup" cfg.verbose = False return cfg class NotThisMethod(Exception): - """Exception raised if a method is not valid for the current scenario.""" -LONG_VERSION_PY = {} -HANDLERS = {} +LONG_VERSION_PY: Dict[str, str] = {} +HANDLERS: Dict[str, Dict[str, Callable]] = {} -def register_vcs_handler(vcs, method): # decorator - """Decorator to mark a method as the handler for a particular VCS.""" - def decorate(f): +def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator + """Create decorator to mark a method as the handler of a VCS.""" + def decorate(f: Callable) -> Callable: """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} @@ -70,97 +77,142 @@ def decorate(f): return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): +def run_command( + commands: List[str], + args: List[str], + cwd: Optional[str] = None, + verbose: bool = False, + hide_stderr: bool = False, + env: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[str], Optional[int]]: """Call the given command(s).""" assert isinstance(commands, list) - p = None - for c in commands: + process = None + + popen_kwargs: Dict[str, Any] = {} + if sys.platform == "win32": + # This hides the console window if pythonw.exe is used + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + popen_kwargs["startupinfo"] = startupinfo + + for command in commands: try: - dispcmd = str([c] + args) + dispcmd = str([command] + args) # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) + process = subprocess.Popen([command] + args, cwd=cwd, env=env, + stdout=subprocess.PIPE, + stderr=(subprocess.PIPE if hide_stderr + else None), **popen_kwargs) break - except EnvironmentError: - e = sys.exc_info()[1] + except OSError as e: if e.errno == errno.ENOENT: continue if verbose: print("unable to run %s" % dispcmd) print(e) - return None + return None, None else: if verbose: print("unable to find command, tried %s" % (commands,)) - return None - stdout = p.communicate()[0].strip() - if sys.version_info[0] >= 3: - stdout = stdout.decode() - if p.returncode != 0: + return None, None + stdout = process.communicate()[0].strip().decode() + if process.returncode != 0: if verbose: print("unable to run %s (error)" % dispcmd) - return None - return stdout + print("stdout was %s" % stdout) + return None, process.returncode + return stdout, process.returncode -def versions_from_parentdir(parentdir_prefix, root, verbose): +def versions_from_parentdir( + parentdir_prefix: str, + root: str, + verbose: bool, +) -> Dict[str, Any]: """Try to determine the version from the parent directory name. - Source tarballs conventionally unpack into a directory that includes - both the project name and a version string. + Source tarballs conventionally unpack into a directory that includes both + the project name and a version string. We will also support searching up + two directory levels for an appropriately named parent directory """ - dirname = os.path.basename(root) - if not dirname.startswith(parentdir_prefix): - if verbose: - print("guessing rootdir is '%s', but '%s' doesn't start with " - "prefix '%s'" % (root, dirname, parentdir_prefix)) - raise NotThisMethod("rootdir doesn't start with parentdir_prefix") - return {"version": dirname[len(parentdir_prefix):], - "full-revisionid": None, - "dirty": False, "error": None} + rootdirs = [] + + for _ in range(3): + dirname = os.path.basename(root) + if dirname.startswith(parentdir_prefix): + return {"version": dirname[len(parentdir_prefix):], + "full-revisionid": None, + "dirty": False, "error": None, "date": None} + rootdirs.append(root) + root = os.path.dirname(root) # up a level + + if verbose: + print("Tried directories %s but none started with prefix %s" % + (str(rootdirs), parentdir_prefix)) + raise NotThisMethod("rootdir doesn't start with parentdir_prefix") @register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): +def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. - keywords = {} + keywords: Dict[str, str] = {} try: - f = open(versionfile_abs, "r") - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - keywords["full"] = mo.group(1) - f.close() - except EnvironmentError: + with open(versionfile_abs, "r") as fobj: + for line in fobj: + if line.strip().startswith("git_refnames ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["refnames"] = mo.group(1) + if line.strip().startswith("git_full ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["full"] = mo.group(1) + if line.strip().startswith("git_date ="): + mo = re.search(r'=\s*"(.*)"', line) + if mo: + keywords["date"] = mo.group(1) + except OSError: pass return keywords @register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): +def git_versions_from_keywords( + keywords: Dict[str, str], + tag_prefix: str, + verbose: bool, +) -> Dict[str, Any]: """Get version information from git keywords.""" - if not keywords: - raise NotThisMethod("no keywords at all, weird") + if "refnames" not in keywords: + raise NotThisMethod("Short version file found") + date = keywords.get("date") + if date is not None: + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] + + # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant + # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 + # -like" string, which we must then edit to make compliant), because + # it's been around since git-1.5.3, and it's too difficult to + # discover which version we're using, or to work around using an + # older one. + date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) refnames = keywords["refnames"].strip() if refnames.startswith("$Format"): if verbose: print("keywords are unexpanded, not using") raise NotThisMethod("unexpanded keywords, not a git-archive tarball") - refs = set([r.strip() for r in refnames.strip("()").split(",")]) + refs = {r.strip() for r in refnames.strip("()").split(",")} # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) + tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} if not tags: # Either we're using git < 1.8.3, or there really are no tags. We use # a heuristic: assume all version tags have a digit. The old git %d @@ -169,77 +221,118 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): # between branches and tags. By ignoring refnames without digits, we # filter out many common branch names like "release" and # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) + tags = {r for r in refs if re.search(r'\d', r)} if verbose: - print("discarding '%s', no digits" % ",".join(refs-tags)) - - branches = [r for r in refs if not r.startswith(TAG) - and r != "HEAD" and not r.startswith("refs/")] - if verbose: - print("likely branches: %s" % ",".join(sorted(branches))) - branch = None - if branches: - branch = branches[0] - + print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: print("likely tags: %s" % ",".join(sorted(tags))) for ref in sorted(tags): # sorting will prefer e.g. "2.0" over "2.0rc1" if ref.startswith(tag_prefix): r = ref[len(tag_prefix):] + # Filter out refs that exactly match prefix or that don't start + # with a number once the prefix is stripped (mostly a concern + # when prefix is '') + if not re.match(r'\d', r): + continue if verbose: print("picking %s" % r) - - result = {"version": r, - "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": None} - if branch is not None: - result["branch"] = branch - return result + return {"version": r, + "full-revisionid": keywords["full"].strip(), + "dirty": False, "error": None, + "date": date} # no suitable tags, so version is "0+unknown", but full hex is still there if verbose: print("no suitable tags, using unknown + full revision id") return {"version": "0+unknown", "full-revisionid": keywords["full"].strip(), - "dirty": False, "error": "no suitable tags"} + "dirty": False, "error": "no suitable tags", "date": None} @register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): +def git_pieces_from_vcs( + tag_prefix: str, + root: str, + verbose: bool, + runner: Callable = run_command +) -> Dict[str, Any]: """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* expanded, and _version.py hasn't already been rewritten with a short version string, meaning we're inside a checked out source tree. """ - if not os.path.exists(os.path.join(root, ".git")): - if verbose: - print("no .git in %s" % root) - raise NotThisMethod("no .git directory") - GITS = ["git"] if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] + + # GIT_DIR can interfere with correct operation of Versioneer. + # It may be intended to be passed to the Versioneer-versioned project, + # but that should not change where we get our version from. + env = os.environ.copy() + env.pop("GIT_DIR", None) + runner = functools.partial(runner, env=env) + + _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, + hide_stderr=not verbose) + if rc != 0: + if verbose: + print("Directory %s not under git control" % root) + raise NotThisMethod("'git rev-parse --git-dir' returned error") + # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out = run_command(GITS, ["describe", "--tags", "--dirty", - "--always", "--long", - "--match", "%s*" % tag_prefix], - cwd=root) + describe_out, rc = runner(GITS, [ + "describe", "--tags", "--dirty", "--always", "--long", + "--match", f"{tag_prefix}[[:digit:]]*" + ], cwd=root) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") describe_out = describe_out.strip() - full_out = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) + full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) if full_out is None: raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() - pieces = {} + pieces: Dict[str, Any] = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None + branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], + cwd=root) + # --abbrev-ref was added in git-1.6.3 + if rc != 0 or branch_name is None: + raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") + branch_name = branch_name.strip() + + if branch_name == "HEAD": + # If we aren't exactly on a branch, pick a branch which represents + # the current commit. If all else fails, we are on a branchless + # commit. + branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) + # --contains was added in git-1.5.4 + if rc != 0 or branches is None: + raise NotThisMethod("'git branch --contains' returned error") + branches = branches.split("\n") + + # Remove the first line if we're running detached + if "(" in branches[0]: + branches.pop(0) + + # Strip off the leading "* " from the list of branches. + branches = [branch[2:] for branch in branches] + if "master" in branches: + branch_name = "master" + elif not branches: + branch_name = None + else: + # Pick the first branch that is returned. Good or bad. + branch_name = branches[0] + + pieces["branch"] = branch_name + # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] # TAG might have hyphens. git_describe = describe_out @@ -250,20 +343,13 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): if dirty: git_describe = git_describe[:git_describe.rindex("-dirty")] - # figure out our branch - abbrev_ref_out = run_command(GITS, - ["rev-parse", "--abbrev-ref", "HEAD"], - cwd=root) - if abbrev_ref_out is not None: - pieces["branch"] = abbrev_ref_out.strip() - # now we have TAG-NUM-gHEX or HEX if "-" in git_describe: # TAG-NUM-gHEX mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) if not mo: - # unparseable. Maybe git-describe is misbehaving? + # unparsable. Maybe git-describe is misbehaving? pieces["error"] = ("unable to parse git-describe output: '%s'" % describe_out) return pieces @@ -288,136 +374,27 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out = run_command(GITS, ["rev-list", "HEAD", "--count"], - cwd=root) - pieces["distance"] = int(count_out) # total number of commits - - return pieces + out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) + pieces["distance"] = len(out.split()) # total number of commits + # commit date: see ISO-8601 comment in git_versions_from_keywords() + date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() + # Use only the last line. Previous lines may contain GPG signature + # information. + date = date.splitlines()[-1] + pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) -@register_vcs_handler("git", "parse_lookup_file") -def git_parse_lookup_file(path): - """Parse a versioneer lookup file. + return pieces - This file allows definition of branch specific data like virtual tags or - custom styles to use for version rendering. - """ - if not os.path.exists(path): - return [] - - import re - lookup = [] - with open(path, "r") as f: - for line in f: - if '#' in line: - line = line[:line.rindex("#")] - line = line.strip() - if not line: - continue - try: - split_line = map(lambda x: x.strip(), line.split()) - if not len(split_line): - continue - - matcher = re.compile(split_line[0]) - - if len(split_line) == 1: - entry = [matcher, None, None, None] - elif len(split_line) == 2: - render = split_line[1] - entry = [matcher, render, None, None] - elif len(split_line) == 3: - tag, ref_commit = split_line[1:] - entry = [matcher, None, tag, ref_commit] - elif len(split_line) == 4: - tag, ref_commit, render = split_line[1:] - entry = [matcher, render, tag, ref_commit] - else: - continue - - lookup.append(entry) - except: - break - return lookup - - -@register_vcs_handler("git", "pieces_from_lookup") -def git_pieces_from_lookup(lookup, root, verbose, run_command=run_command): - """Extract version information based on provided lookup data.""" - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - - stdout = run_command(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], - cwd=root) - if stdout is None: - raise NotThisMethod("git rev-parse --abbrev-ref HEAD failed") - - current_branch = stdout.strip() - for matcher, render, tag, ref_commit in lookup: - if matcher.match(current_branch): - if tag is None or ref_commit is None: - raise NotThisMethod("tag or ref_commit is unset for " - "this branch") - - stdout = run_command(GITS, - ["rev-list", "%s..HEAD" % ref_commit, - "--count"], - cwd=root) - if stdout is None: - raise NotThisMethod("git rev-list %s..HEAD " - "--count failed" % ref_commit) - try: - num_commits = int(stdout.strip()) - except ValueError: - raise NotThisMethod("git rev-list %s..HEAD --count didn't " - "return a valid number" % ref_commit) - - stdout = run_command(GITS, - ["rev-parse", "--short", "HEAD"], - cwd=root) - if stdout is None: - raise NotThisMethod("git describe rev-parse " - "--short HEAD failed") - short_hash = stdout.strip() - - stdout = run_command(GITS, - ["describe", "--tags", - "--dirty", "--always"], - cwd=root) - if stdout is None: - raise NotThisMethod("git describe --tags --dirty " - "--always failed") - dirty = stdout.strip().endswith("-dirty") - - stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) - if stdout is None: - raise NotThisMethod("git rev-parse HEAD failed") - full = stdout.strip() - - return { - "long": full, - "short": short_hash, - "dirty": dirty, - "branch": current_branch, - "closest-tag": tag, - "distance": num_commits, - "error": None, - "render": render - } - - raise NotThisMethod("no matching lookup definition found") - - -def plus_or_dot(pieces): +def plus_or_dot(pieces: Dict[str, Any]) -> str: """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" -def render_pep440(pieces): +def render_pep440(pieces: Dict[str, Any]) -> str: """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you @@ -442,23 +419,71 @@ def render_pep440(pieces): return rendered -def render_pep440_pre(pieces): - """TAG[.post.devDISTANCE] -- No -dirty. +def render_pep440_branch(pieces: Dict[str, Any]) -> str: + """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . + + The ".dev0" means not master branch. Note that .dev0 sorts backwards + (a feature branch will appear "older" than the master branch). Exceptions: - 1: no tags. 0.post.devDISTANCE + 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] + if pieces["distance"] or pieces["dirty"]: + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + else: + # exception #1 + rendered = "0" + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+untagged.%d.g%s" % (pieces["distance"], + pieces["short"]) + if pieces["dirty"]: + rendered += ".dirty" + return rendered + + +def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: + """Split pep440 version string at the post-release segment. + + Returns the release segments before the post-release and the + post-release version number (or -1 if no post-release segment is present). + """ + vc = str.split(ver, ".post") + return vc[0], int(vc[1] or 0) if len(vc) == 2 else None + + +def render_pep440_pre(pieces: Dict[str, Any]) -> str: + """TAG[.postN.devDISTANCE] -- No -dirty. + + Exceptions: + 1: no tags. 0.post0.devDISTANCE + """ + if pieces["closest-tag"]: if pieces["distance"]: - rendered += ".post.dev%d" % pieces["distance"] + # update the post release segment + tag_version, post_version = pep440_split_post(pieces["closest-tag"]) + rendered = tag_version + if post_version is not None: + rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) + else: + rendered += ".post0.dev%d" % (pieces["distance"]) + else: + # no commits, use the tag as the version + rendered = pieces["closest-tag"] else: # exception #1 - rendered = "0.post.dev%d" % pieces["distance"] + rendered = "0.post0.dev%d" % pieces["distance"] return rendered -def render_pep440_post(pieces): +def render_pep440_post(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards @@ -485,33 +510,41 @@ def render_pep440_post(pieces): return rendered -def render_pep440_dev(pieces): - """TAG[.devDISTANCE]+gHEX[.dirty] . +def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: + """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . + + The ".dev0" means not master branch. Exceptions: - 1: no tags. 0.devDISTANCE+gHEX[.dirty] + 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] """ if pieces["closest-tag"]: rendered = pieces["closest-tag"] - if pieces["distance"]: - rendered += ".dev%d" % pieces["distance"] - rendered += plus_or_dot(pieces) + if pieces["distance"] or pieces["dirty"]: + rendered += ".post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += plus_or_dot(pieces) + rendered += "g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" else: # exception #1 - rendered = "0.dev%d" % pieces["distance"] - rendered += "+" - rendered += "g%s" % pieces["short"] - if pieces["dirty"]: - rendered += ".dirty" + rendered = "0.post%d" % pieces["distance"] + if pieces["branch"] != "master": + rendered += ".dev0" + rendered += "+g%s" % pieces["short"] + if pieces["dirty"]: + rendered += ".dirty" return rendered -def render_pep440_old(pieces): +def render_pep440_old(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. - Eexceptions: + Exceptions: 1: no tags. 0.postDISTANCE[.dev0] """ if pieces["closest-tag"]: @@ -528,7 +561,7 @@ def render_pep440_old(pieces): return rendered -def render_git_describe(pieces): +def render_git_describe(pieces: Dict[str, Any]) -> str: """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. @@ -548,7 +581,7 @@ def render_git_describe(pieces): return rendered -def render_git_describe_long(pieces): +def render_git_describe_long(pieces: Dict[str, Any]) -> str: """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. @@ -568,30 +601,30 @@ def render_git_describe_long(pieces): return rendered -def render(pieces, style): +def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: """Render the given version pieces into the requested style.""" if pieces["error"]: return {"version": "unknown", "full-revisionid": pieces.get("long"), "dirty": None, - "error": pieces["error"]} - - if "render" in pieces and pieces["render"] is not None: - style = pieces["render"] + "error": pieces["error"], + "date": None} if not style or style == "default": style = "pep440" # the default if style == "pep440": rendered = render_pep440(pieces) + elif style == "pep440-branch": + rendered = render_pep440_branch(pieces) elif style == "pep440-pre": rendered = render_pep440_pre(pieces) elif style == "pep440-post": rendered = render_pep440_post(pieces) + elif style == "pep440-post-branch": + rendered = render_pep440_post_branch(pieces) elif style == "pep440-old": rendered = render_pep440_old(pieces) - elif style == "pep440-dev": - rendered = render_pep440_dev(pieces) elif style == "git-describe": rendered = render_git_describe(pieces) elif style == "git-describe-long": @@ -599,14 +632,12 @@ def render(pieces, style): else: raise ValueError("unknown style '%s'" % style) - result = {"version": rendered, "full-revisionid": pieces["long"], - "dirty": pieces["dirty"], "error": None} - if "branch" in pieces and pieces["branch"] is not None: - result["branch"] = pieces["branch"] - return result + return {"version": rendered, "full-revisionid": pieces["long"], + "dirty": pieces["dirty"], "error": None, + "date": pieces.get("date")} -def get_versions(): +def get_versions() -> Dict[str, Any]: """Get version information or return default if unable to do so.""" # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some @@ -627,22 +658,13 @@ def get_versions(): # versionfile_source is the relative path from the top of the source # tree (where the .git directory might live) to this file. Invert # this to find the root from __file__. - for i in cfg.versionfile_source.split('/'): + for _ in cfg.versionfile_source.split('/'): root = os.path.dirname(root) except NameError: return {"version": "0+unknown", "full-revisionid": None, "dirty": None, - "error": "unable to find root of source tree"} - - lookupfile = cfg.lookupfile if cfg.lookupfile is not None else ".versioneer-lookup" - lookuppath = os.path.join(root, lookupfile) - if os.path.exists(lookuppath): - try: - lookup_data = git_parse_lookup_file(lookuppath) - pieces = git_pieces_from_lookup(lookup_data, root, verbose) - return render(pieces, cfg.style) - except NotThisMethod: - pass + "error": "unable to find root of source tree", + "date": None} try: pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) @@ -658,4 +680,4 @@ def get_versions(): return {"version": "0+unknown", "full-revisionid": None, "dirty": None, - "error": "unable to compute version"} + "error": "unable to compute version", "date": None} diff --git a/src/octoprint/access/__init__.py b/src/octoprint/access/__init__.py new file mode 100644 index 0000000000..52f1849bf6 --- /dev/null +++ b/src/octoprint/access/__init__.py @@ -0,0 +1,6 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +ADMIN_GROUP = "admins" +USER_GROUP = "users" +GUEST_GROUP = "guests" +READONLY_GROUP = "readonly" diff --git a/src/octoprint/access/groups.py b/src/octoprint/access/groups.py new file mode 100644 index 0000000000..5bb22cf0a5 --- /dev/null +++ b/src/octoprint/access/groups.py @@ -0,0 +1,750 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +__author__ = "Marc Hannappel " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2017 The OctoPrint Project - Released under terms of the AGPLv3 License" + +import io +import logging +import os +from functools import partial + +import yaml + +from octoprint.access import ADMIN_GROUP, GUEST_GROUP, READONLY_GROUP, USER_GROUP +from octoprint.access.permissions import OctoPrintPermission, Permissions +from octoprint.settings import settings +from octoprint.util import atomic_write +from octoprint.vendor.flask_principal import Need, Permission + +GroupNeed = partial(Need, "group") +GroupNeed.__doc__ = """A need with the method preset to `"group"`.""" + + +class GroupPermission(Permission): + def __init__(self, key): + need = GroupNeed(key) + super(GroupPermission, self).__init__(need) + + +class GroupManager(object): + @classmethod + def default_permissions_for_group(cls, group): + result = [] + for permission in Permissions.all(): + if group in permission.default_groups: + result.append(permission) + return result + + def __init__(self): + self._logger = logging.getLogger(__name__) + self._group_change_listeners = [] + + self._default_groups = [] + self._init_defaults() + + @property + def groups(self): + return [] + + @property + def admin_group(self): + return self.find_group(ADMIN_GROUP) + + @property + def user_group(self): + return self.find_group(USER_GROUP) + + @property + def guest_group(self): + return self.find_group(GUEST_GROUP) + + def _init_defaults(self): + self._default_groups = { + ADMIN_GROUP: { + "name": "Admins", + "description": "Administrators", + "permissions": self.default_permissions_for_group(ADMIN_GROUP), + "subgroups": [], + "changeable": False, + "removable": False, + "default": False, + "toggleable": True, + }, + USER_GROUP: { + "name": "Operator", + "description": "Group to gain operator access", + "permissions": self.default_permissions_for_group(USER_GROUP), + "subgroups": [], + "changeable": True, + "default": True, + "removable": False, + "toggleable": True, + }, + GUEST_GROUP: { + "name": "Guests", + "description": "Anyone who is not currently logged in", + "permissions": self.default_permissions_for_group(GUEST_GROUP), + "subgroups": [], + "changeable": True, + "default": False, + "removable": False, + "toggleable": False, + }, + READONLY_GROUP: { + "name": "Read-only Access", + "description": "Group to gain read-only access", + "permissions": self.default_permissions_for_group(READONLY_GROUP), + "subgroups": [], + "changeable": False, + "removable": False, + "default": False, + "toggleable": True, + }, + } + + for key, g in self._default_groups.items(): + self.add_group( + key, + g["name"], + g["description"], + g["permissions"], + g["subgroups"], + changeable=g.get("changeable", True), + removable=g.get("removable", True), + default=g.get("default", False), + toggleable=g.get("toggleable", True), + save=False, + ) + + def register_listener(self, listener): + self._group_change_listeners.append(listener) + + def unregister_listener(self, listener): + self._group_change_listeners.remove(listener) + + def add_group( + self, + key, + name, + description, + permissions, + subgroups, + default=False, + removable=True, + changeable=True, + toggleable=True, + save=True, + notify=True, + ): + pass + + def update_group( + self, + key, + description=None, + permissions=None, + subgroups=None, + default=None, + save=True, + notify=True, + ): + pass + + def remove_group(self, key, save=True, notify=True): + pass + + def find_group(self, key): + return None + + def _to_permissions(self, *permissions): + return list( + filter( + lambda x: x is not None, + [Permissions.find(permission) for permission in permissions], + ) + ) + + def _from_permissions(self, *permissions): + return [permission.key for permission in permissions] + + def _from_groups(self, *groups): + return [group.key for group in groups] + + def _to_groups(self, *groups): + return list(filter(lambda x: x is not None, [self._to_group(g) for g in groups])) + + def _to_group(self, group): + # noinspection PyCompatibility + if isinstance(group, Group): + return group + elif isinstance(group, str): + return self.find_group(group) + elif isinstance(group, dict): + return self.find_group(group.get("key")) + else: + return None + + def _notify_listeners(self, action, group, *args, **kwargs): + method = "on_group_{}".format(action) + for listener in self._group_change_listeners: + try: + getattr(listener, method)(group, *args, **kwargs) + except Exception: + self._logger.exception( + "Error notifying listener {!r} via {}".format(listener, method) + ) + + +class GroupChangeListener(object): + def on_group_added(self, group): + pass + + def on_group_removed(self, group): + pass + + def on_group_permissions_changed(self, group, added=None, removed=None): + pass + + def on_group_subgroups_changed(self, group, added=None, removed=None): + pass + + +class FilebasedGroupManager(GroupManager): + FILE_VERSION = 2 + + def __init__(self, path=None): + if path is None: + path = settings().get(["accessControl", "groupfile"]) + if path is None: + path = os.path.join(settings().getBaseFolder("base"), "groups.yaml") + + self._groupfile = path + self._groups = {} + self._dirty = False + + GroupManager.__init__(self) + + self._load() + + def _load(self): + if os.path.exists(self._groupfile) and os.path.isfile(self._groupfile): + try: + with io.open(self._groupfile, "rt", encoding="utf-8") as f: + data = yaml.safe_load(f) + + if "groups" not in data: + groups = data + data = {"groups": groups} + + file_version = data.get("_version", 1) + if file_version < self.FILE_VERSION: + # make sure we migrate the file on disk after loading + self._logger.info( + "Detected file version {} on group " + "storage, migrating to version {}".format( + file_version, self.FILE_VERSION + ) + ) + self._dirty = True + + groups = data.get("groups", {}) + tracked_permissions = data.get("tracked", list()) + + for key, attributes in groups.items(): + if key in self._default_groups: + # group is a default group + if not self._default_groups[key].get("changeable", True): + # group may not be changed -> bail + continue + + name = self._default_groups[key].get("name", "") + description = self._default_groups[key].get("description", "") + removable = self._default_groups[key].get("removable", True) + changeable = self._default_groups[key].get("changeable", True) + toggleable = self._default_groups[key].get("toggleable", True) + + if file_version == 1: + # 1.4.0/file version 1 has a bug that resets default to True for users group on modification + set_default = self._default_groups[key].get("default", False) + else: + set_default = attributes.get("default", False) + else: + name = attributes.get("name", "") + description = attributes.get("description", "") + removable = True + changeable = True + toggleable = True + set_default = attributes.get("default", False) + + permissions = self._to_permissions(*attributes.get("permissions", [])) + default_permissions = self.default_permissions_for_group(key) + for permission in default_permissions: + if ( + permission.key not in tracked_permissions + and permission not in permissions + ): + permissions.append(permission) + + subgroups = self._to_groups(*attributes.get("subgroups", [])) + + group = Group( + key, + name, + description=description, + permissions=permissions, + subgroups=subgroups, + default=set_default, + removable=removable, + changeable=changeable, + toggleable=toggleable, + ) + + if key == GUEST_GROUP and ( + len(group.permissions) != len(permissions) + or len(group.subgroups) != len(subgroups) + ): + self._logger.warning( + "Dangerous permissions and/or subgroups stripped from guests group" + ) + self._dirty = True + + self._groups[key] = group + + for group in self._groups.values(): + group._subgroups = self._to_groups(*group._subgroups) + + if self._dirty: + self._save() + + except Exception: + self._logger.exception( + "Error while loading groups from file {}".format(self._groupfile) + ) + + def _save(self, force=False): + if self._groupfile is None or not self._dirty and not force: + return + + groups = {} + for key in self._groups.keys(): + group = self._groups[key] + groups[key] = { + "permissions": self._from_permissions(*group._permissions), + "subgroups": self._from_groups(*group._subgroups), + "default": group._default, + } + if key not in self._default_groups: + groups[key]["name"] = group.get_name() + groups[key]["description"] = group.get_description() + + data = { + "_version": self.FILE_VERSION, + "groups": groups, + "tracked": [x.key for x in Permissions.all()], + } + + with atomic_write( + self._groupfile, mode="wt", permissions=0o600, max_permissions=0o666 + ) as f: + import yaml + + yaml.safe_dump( + data, f, default_flow_style=False, indent=2, allow_unicode=True + ) + self._dirty = False + self._load() + + @property + def groups(self): + return list(self._groups.values()) + + @property + def default_groups(self): + return [group for group in self._groups.values() if group.is_default()] + + def find_group(self, key): + if key is None: + return None + return self._groups.get(key) + + def add_group( + self, + key, + name, + description, + permissions, + subgroups, + default=False, + removable=True, + changeable=True, + toggleable=True, + overwrite=False, + notify=True, + save=True, + ): + if key in self._groups and not overwrite: + raise GroupAlreadyExists(key) + + if not permissions: + permissions = [] + + permissions = self._to_permissions(*permissions) + assert all(map(lambda p: isinstance(p, OctoPrintPermission), permissions)) + + subgroups = self._to_groups(*subgroups) + assert all(map(lambda g: isinstance(g, Group), subgroups)) + + group = Group( + key, + name, + description=description, + permissions=permissions, + subgroups=subgroups, + default=default, + changeable=changeable, + removable=removable, + toggleable=toggleable, + ) + self._groups[key] = group + + if save: + self._dirty = True + self._save() + + if notify: + self._notify_listeners("added", group) + + def remove_group(self, key, save=True, notify=True): + """Removes a Group by key""" + group = self._to_group(key) + if group is None: + raise UnknownGroup(key) + + if not group.is_removable(): + raise GroupUnremovable(key) + + del self._groups[key] + self._dirty = True + + if save: + self._save() + + if notify: + self._notify_listeners("removed", group) + + def update_group( + self, + key, + description=None, + permissions=None, + subgroups=None, + default=None, + save=True, + notify=True, + ): + group = self._to_group(key) + if group is None: + raise UnknownGroup(key) + + if not group.is_changeable(): + raise GroupCantBeChanged(key) + + if description is not None and description != group.get_description(): + group.change_description(description) + self._dirty = True + + notifications = [] + + if permissions is not None: + permissions = self._to_permissions(*permissions) + assert all(map(lambda p: isinstance(p, OctoPrintPermission), permissions)) + + removed_permissions = list(set(group._permissions) - set(permissions)) + added_permissions = list(set(permissions) - set(group._permissions)) + + if removed_permissions: + self._dirty |= group.remove_permissions_from_group(removed_permissions) + if added_permissions: + self._dirty |= group.add_permissions_to_group(added_permissions) + + notifications.append( + ( + ("permissions_changed", group), + {"added": added_permissions, "removed": removed_permissions}, + ) + ) + + if subgroups is not None: + subgroups = self._to_groups(*subgroups) + assert all(map(lambda g: isinstance(g, Group), subgroups)) + + removed_subgroups = list(set(group._subgroups) - set(subgroups)) + added_subgroups = list(set(subgroups) - set(group._subgroups)) + + if removed_subgroups: + self._dirty = group.remove_subgroups_from_group(removed_subgroups) + if added_subgroups: + self._dirty = group.add_subgroups_to_group(added_subgroups) + + notifications.append( + ( + ("subgroups_changed", group), + {"added": added_subgroups, "removed": removed_subgroups}, + ) + ) + + if default is not None: + group.change_default(default) + self._dirty = True + + if self._dirty: + if save: + self._save() + + if notify: + for args, kwargs in notifications: + self._notify_listeners(*args, **kwargs) + + +class GroupAlreadyExists(Exception): + def __init__(self, key): + Exception.__init__(self, "Group %s already exists" % key) + + +class UnknownGroup(Exception): + def __init__(self, key): + Exception.__init__(self, "Unknown group: %s" % key) + + +class GroupUnremovable(Exception): + def __init__(self, key): + Exception.__init__(self, "Group can't be removed: %s" % key) + + +class GroupCantBeChanged(Exception): + def __init__(self, key): + Exception.__init__(self, "Group can't be changed: %s" % key) + + +class Group(object): + def __init__( + self, + key, + name, + description="", + permissions=None, + subgroups=None, + default=False, + removable=True, + changeable=True, + toggleable=True, + ): + if permissions is None: + permissions = [] + if subgroups is None: + subgroups = [] + + if key == GUEST_GROUP: + # guests may not have any dangerous permissions + permissions = list(filter(lambda p: not p.dangerous, permissions)) + subgroups = list(filter(lambda g: not g.dangerous, subgroups)) + + self._key = key + self._name = name + self._description = description + self._permissions = permissions + self._subgroups = subgroups + self._default = default + self._removable = removable + self._changeable = changeable + self._toggleable = toggleable + + def as_dict(self): + from octoprint.access.permissions import OctoPrintPermission + + return { + "key": self.key, + "name": self.get_name(), + "description": self._description, + "permissions": list(map(lambda p: p.key, self._permissions)), + "subgroups": list(map(lambda g: g.key, self._subgroups)), + "needs": OctoPrintPermission.convert_needs_to_dict(self.needs), + "default": self._default, + "removable": self._removable, + "changeable": self._changeable, + "toggleable": self._toggleable, + "dangerous": self.dangerous, + } + + @property + def key(self): + return self._key + + def get_name(self): + return self._name + + def get_description(self): + return self._description + + def is_default(self): + return self._default + + def is_changeable(self): + return self._changeable + + def is_removable(self): + return self._removable + + def is_toggleable(self): + return self._toggleable + + @property + def dangerous(self): + return any(map(lambda p: p.dangerous, self._permissions)) or any( + map(lambda g: g.dangerous, self._subgroups) + ) + + def add_permissions_to_group(self, permissions): + """Adds a list of permissions to a group""" + if not self.is_changeable(): + raise GroupCantBeChanged(self.key) + + # Make sure the permissions variable is of type list + if not isinstance(permissions, list): + permissions = [permissions] + + assert all(map(lambda p: isinstance(p, OctoPrintPermission), permissions)) + + if self.key == GUEST_GROUP: + # don't allow dangerous permissions on the guests group + permissions = list(filter(lambda p: not p.dangerous, permissions)) + + dirty = False + for permission in permissions: + if permissions not in self.permissions: + self._permissions.append(permission) + dirty = True + + return dirty + + def remove_permissions_from_group(self, permissions): + """Removes a list of permissions from a group""" + if not self.is_changeable(): + raise GroupCantBeChanged(self.key) + + # Make sure the permissions variable is of type list + if not isinstance(permissions, list): + permissions = [permissions] + + assert all(map(lambda p: isinstance(p, OctoPrintPermission), permissions)) + + dirty = False + for permission in permissions: + if permission in self._permissions: + self._permissions.remove(permission) + dirty = True + + return dirty + + def add_subgroups_to_group(self, subgroups): + """Adds a list of subgroups to a group""" + if not self.is_changeable(): + raise GroupCantBeChanged(self.key) + + # Make sure the subgroups variable is of type list + if not isinstance(subgroups, list): + subgroups = [subgroups] + + assert all(map(lambda g: isinstance(g, Group), subgroups)) + + if self.key == GUEST_GROUP: + # don't allow dangerous subgroups on the guests group + subgroups = list(filter(lambda g: not g.dangerous, subgroups)) + + dirty = False + for group in subgroups: + if group.is_toggleable() and group not in self._subgroups: + self._subgroups.append(group) + dirty = True + + return dirty + + def remove_subgroups_from_group(self, subgroups): + """Removes a list of subgroups from a group""" + if not self.is_changeable(): + raise GroupCantBeChanged(self.key) + + # Make sure the subgroups variable is of type list + if not isinstance(subgroups, list): + subgroups = [subgroups] + + assert all(map(lambda g: isinstance(g, Group), subgroups)) + + dirty = False + for group in subgroups: + if group.is_toggleable() and group in self._subgroups: + self._subgroups.remove(group) + dirty = True + + return dirty + + def change_default(self, default): + """Changes the default flag of a Group""" + if not self.is_changeable(): + raise GroupCantBeChanged(self.key) + + self._default = default + + def change_description(self, description): + """Changes the description of a group""" + self._description = description + + @property + def permissions(self): + if Permissions.ADMIN in self._permissions: + return Permissions.all() + + return list(filter(lambda p: p is not None, self._permissions)) + + @property + def subgroups(self): + return list(filter(lambda g: g is not None, self._subgroups)) + + @property + def needs(self): + needs = set() + needs.add(GroupNeed(self.key)) + for p in self.permissions: + needs = needs.union(p.needs) + for g in self.subgroups: + needs = needs.union(g.needs) + + return needs + + def has_permission(self, permission): + if Permissions.ADMIN.get_name() in self._permissions: + return True + + return permission.needs.issubset(self.needs) + + def __repr__(self): + return ( + '{}("{}", "{}", description="{}", permissions={!r}, ' + "default={}, removable={}, changeable={})".format( + self.__class__.__name__, + self._key, + self._name, + self._description, + self._permissions, + bool(self._default), + bool(self._removable), + bool(self._changeable), + ) + ) + + def __hash__(self): + return self.key.__hash__() + + def __eq__(self, other): + return isinstance(other, Group) and other.key == self.key diff --git a/src/octoprint/access/permissions.py b/src/octoprint/access/permissions.py new file mode 100644 index 0000000000..35008a6d87 --- /dev/null +++ b/src/octoprint/access/permissions.py @@ -0,0 +1,465 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +__author__ = "Marc Hannappel " +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2017 The OctoPrint Project - Released under terms of the AGPLv3 License" + +from collections import OrderedDict, defaultdict +from functools import wraps + +from flask import abort, g +from flask_babel import gettext +from future.utils import with_metaclass + +# noinspection PyCompatibility + +from octoprint.access import ADMIN_GROUP, READONLY_GROUP, USER_GROUP +from octoprint.vendor.flask_principal import Need, Permission, PermissionDenied, RoleNeed + + +class OctoPrintPermission(Permission): + @classmethod + def convert_needs_to_dict(cls, needs): + ret_needs = defaultdict(list) + for need in needs: + if need.value not in ret_needs[need.method]: + ret_needs[need.method].append(need.value) + return ret_needs + + @classmethod + def convert_to_needs(cls, needs): + result = [] + for need in needs: + # noinspection PyCompatibility + if isinstance(need, Need): + result.append(need) + elif isinstance(need, Permission): + result += need.needs + elif isinstance(need, str): + result.append(RoleNeed(need)) + return result + + def __init__(self, name, description, *needs, **kwargs): + self._name = name + self._description = description + self._dangerous = kwargs.pop("dangerous", False) + self._default_groups = kwargs.pop("default_groups", []) + + self._key = None + + Permission.__init__(self, *self.convert_to_needs(needs)) + + def as_dict(self): + return { + "key": self.key, + "name": self.get_name(), + "dangerous": self._dangerous, + "default_groups": self._default_groups, + "description": self.get_description(), + "needs": self.convert_needs_to_dict(self.needs), + } + + @property + def key(self): + return self._key + + @key.setter + def key(self, value): + self._key = value + + @property + def dangerous(self): + return self._dangerous + + @property + def default_groups(self): + return self._default_groups + + def get_name(self): + return self._name + + def get_description(self): + return self._description + + def allows(self, identity): + """Whether the identity can access this permission. + Overridden from Permission.allows to make sure the Identity provides ALL + required needs instead of ANY required need. + + :param identity: The identity + """ + if self.needs and len(self.needs.intersection(identity.provides)) != len( + self.needs + ): + return False + + if self.excludes and self.excludes.intersection(identity.provides): + return False + + return True + + def union(self, other): + """Create a new OctoPrintPermission with the requirements of the union of this + and other. + + :param other: The other permission + """ + p = self.__class__(self._name, self._description, *self.needs.union(other.needs)) + p.excludes.update(self.excludes.union(other.excludes)) + return p + + def difference(self, other): + """Create a new OctoPrintPermission consisting of requirements in this + permission and not in the other. + """ + + p = self.__class__( + self._name, self._description, *self.needs.difference(other.needs) + ) + p.excludes.update(self.excludes.difference(other.excludes)) + return p + + def __repr__(self): + return "{}({!r}, {!r}, {})".format( + self.__class__.__name__, + self.get_name(), + self.get_description(), + ", ".join(map(repr, self.needs)), + ) + + def __hash__(self): + return self.get_name().__hash__() + + def __eq__(self, other): + return ( + isinstance(other, OctoPrintPermission) and other.get_name() == self.get_name() + ) + + +class PluginOctoPrintPermission(OctoPrintPermission): + def __init__(self, *args, **kwargs): + self.plugin = kwargs.pop("plugin", None) + OctoPrintPermission.__init__(self, *args, **kwargs) + + def as_dict(self): + result = OctoPrintPermission.as_dict(self) + result["plugin"] = self.plugin + return result + + +class PluginIdentityContext(object): + """Identity context for not initialized Permissions + + Needed to support @Permissions.PLUGIN_X_Y.require() + + Will search the permission when needed + """ + + def __init__(self, key, http_exception=None): + self.key = key + self.http_exception = http_exception + """The permission of this principal + """ + + @property + def identity(self): + """The identity of this principal""" + return g.identity + + def can(self): + """Whether the identity has access to the permission""" + permission = getattr(Permissions, self.key) + if permission is None or isinstance(permission, PluginPermissionDecorator): + raise UnknownPermission(self.key) + + return permission.can() + + def __call__(self, f): + @wraps(f) + def _decorated(*args, **kw): + with self: + rv = f(*args, **kw) + return rv + + return _decorated + + def __enter__(self): + permission = getattr(Permissions, self.key) + if permission is None or isinstance(permission, PluginPermissionDecorator): + raise UnknownPermission(self.key) + + # check the permission here + if not permission.can(): + if self.http_exception: + abort(self.http_exception, permission) + raise PermissionDenied(permission) + + def __exit__(self, *args): + return False + + +class PluginPermissionDecorator(Permission): + """Decorator Class for not initialized Permissions + + Needed to support @Permissions.PLUGIN_X_Y.require() + """ + + def __init__(self, key): + self.key = key + + def require(self, http_exception=None): + return PluginIdentityContext(self.key, http_exception) + + +class PermissionsMetaClass(type): + permissions = OrderedDict() + + def __new__(mcs, name, bases, args): + cls = type.__new__(mcs, name, bases, args) + + for key, value in args.items(): + if isinstance(value, OctoPrintPermission): + value.key = key + mcs.permissions[key] = value + delattr(cls, key) + + return cls + + def __setattr__(cls, key, value): + if isinstance(value, OctoPrintPermission): + if key in cls.permissions: + raise PermissionAlreadyExists(key) + value.key = key + cls.permissions[key] = value + + def __getattr__(cls, key): + permission = cls.permissions.get(key) + + if key.startswith("PLUGIN_") and permission is None: + return PluginPermissionDecorator(key) + + return permission + + def all(cls): + return list(cls.permissions.values()) + + def filter(cls, cb): + return list(filter(cb, cls.all())) + + def find(cls, p, filter=None): + key = None + if isinstance(p, OctoPrintPermission): + key = p.key + elif isinstance(p, dict): + key = p.get("key") + elif isinstance(p, str): + key = p + + if key is None: + return None + + return cls.match(lambda p: p.key == key, filter=filter) + + def match(cls, match, filter=None): + if callable(filter): + permissions = cls.filter(filter) + else: + permissions = cls.all() + + for permission in permissions: + if match(permission): + return permission + + return None + + +class Permissions(with_metaclass(PermissionsMetaClass)): + + # Special permission + ADMIN = OctoPrintPermission( + "Admin", + gettext("Admin is allowed to do everything"), + RoleNeed("admin"), + dangerous=True, + default_groups=[ADMIN_GROUP], + ) + + STATUS = OctoPrintPermission( + "Status", + gettext( + "Allows to gather basic status information, e.g. job progress, " + "printer state, temperatures, ... Mandatory for the default UI " + "to work" + ), + RoleNeed("status"), + default_groups=[USER_GROUP, READONLY_GROUP], + ) + + CONNECTION = OctoPrintPermission( + "Connection", + gettext("Allows to connect to and disconnect from a printer"), + RoleNeed("connection"), + default_groups=[USER_GROUP], + ) + + WEBCAM = OctoPrintPermission( + "Webcam", + gettext("Allows to watch the webcam stream"), + RoleNeed("webcam"), + default_groups=[USER_GROUP, READONLY_GROUP], + ) + + SYSTEM = OctoPrintPermission( + "System", + gettext( + "Allows to run system commands, e.g. restart OctoPrint, " + "shutdown or reboot the system, and to retrieve system and usage information" + ), + RoleNeed("system"), + dangerous=True, + ) + + FILES_LIST = OctoPrintPermission( + "File List", + gettext( + "Allows to retrieve a list of all uploaded files and folders, including" + "their metadata (e.g. date, file size, analysis results, ...)" + ), + RoleNeed("files_list"), + default_groups=[USER_GROUP, READONLY_GROUP], + ) + FILES_UPLOAD = OctoPrintPermission( + "File Upload", + gettext( + "Allows users to upload new files, create new folders and copy existing ones. If " + "the File Delete permission is also set, File Upload also allows " + "moving files and folders." + ), + RoleNeed("files_upload"), + default_groups=[USER_GROUP], + ) + FILES_DOWNLOAD = OctoPrintPermission( + "File Download", + gettext( + "Allows users to download files. The GCODE viewer is " + "affected by this as well." + ), + RoleNeed("files_download"), + default_groups=[USER_GROUP, READONLY_GROUP], + ) + FILES_DELETE = OctoPrintPermission( + "File Delete", + gettext( + "Allows users to delete files and folders. If the File Upload permission is " + "also set, File Delete also allows moving files and folders." + ), + RoleNeed("files_delete"), + default_groups=[USER_GROUP], + ) + FILES_SELECT = OctoPrintPermission( + "File Select", + gettext("Allows to select a file for printing"), + RoleNeed("files_select"), + default_groups=[USER_GROUP], + ) + + PRINT = OctoPrintPermission( + "Print", + gettext("Allows to start, pause and cancel a print job"), + RoleNeed("print"), + default_groups=[USER_GROUP], + ) + + GCODE_VIEWER = OctoPrintPermission( + "GCODE viewer", + gettext( + 'Allows access to the GCODE viewer if the "File Download"' + "permission is also set." + ), + RoleNeed("gcodeviewer"), + default_groups=[USER_GROUP, READONLY_GROUP], + ) + + MONITOR_TERMINAL = OctoPrintPermission( + "Terminal", + gettext( + "Allows to watch the terminal tab but not to send commands " + "to the printer from it" + ), + RoleNeed("monitor_terminal"), + default_groups=[USER_GROUP, READONLY_GROUP], + ) + + CONTROL = OctoPrintPermission( + "Control", + gettext( + "Allows to control of the printer by using the temperature controls," + "the control tab or sending commands through the terminal." + ), + RoleNeed("control"), + default_groups=[USER_GROUP], + ) + + SLICE = OctoPrintPermission( + "Slice", + gettext("Allows to slice files"), + RoleNeed("slice"), + default_groups=[USER_GROUP], + ) + + TIMELAPSE_LIST = OctoPrintPermission( + "Timelapse List", + gettext("Allows to list timelapse videos"), + RoleNeed("timelapse_list"), + default_groups=[USER_GROUP, READONLY_GROUP], + ) + TIMELAPSE_DOWNLOAD = OctoPrintPermission( + "Timelapse Download", + gettext("Allows to download timelapse videos"), + RoleNeed("timelapse_download"), + default_groups=[USER_GROUP, READONLY_GROUP], + ) + TIMELAPSE_DELETE = OctoPrintPermission( + "Timelapse Delete", + gettext("Allows to delete timelapse videos and unrendered timelapses"), + RoleNeed("timelapse_delete"), + default_groups=[USER_GROUP], + ) + TIMELAPSE_ADMIN = OctoPrintPermission( + "Timelapse Admin", + gettext( + "Allows to change the timelapse settings and delete or " + 'render unrendered timelapses. Includes the "Timelapse List",' + '"Timelapse Delete" and "Timelapse Download" permissions' + ), + RoleNeed("timelapse_admin"), + TIMELAPSE_LIST, + TIMELAPSE_DOWNLOAD, + default_groups=[USER_GROUP], + ) + + SETTINGS_READ = OctoPrintPermission( + "Settings Access", + gettext( + "Allows to read non sensitive settings. Mandatory for the " + "default UI to work." + ), + RoleNeed("settings_read"), + default_groups=[USER_GROUP, READONLY_GROUP], + ) + SETTINGS = OctoPrintPermission( + "Settings Admin", + gettext("Allows to manage settings and also to read sensitive settings"), + RoleNeed("settings"), + dangerous=True, + ) + + +class PermissionAlreadyExists(Exception): + def __init__(self, permission): + Exception.__init__(self, "Permission %s already exists" % permission) + + +class UnknownPermission(Exception): + def __init__(self, permissionname): + Exception.__init__(self, "Unknown permission: %s" % permissionname) diff --git a/src/octoprint/access/users.py b/src/octoprint/access/users.py new file mode 100644 index 0000000000..c9d59f49cf --- /dev/null +++ b/src/octoprint/access/users.py @@ -0,0 +1,1444 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" + +import hashlib +import io +import logging +import os +import uuid + +# noinspection PyCompatibility +from builtins import bytes, range + +import wrapt +import yaml +from flask_login import AnonymousUserMixin, UserMixin +from werkzeug.local import LocalProxy + +from octoprint.access.groups import Group, GroupChangeListener +from octoprint.access.permissions import OctoPrintPermission, Permissions +from octoprint.settings import settings as s +from octoprint.util import atomic_write, deprecated, generate_api_key +from octoprint.util import get_fully_qualified_classname as fqcn +from octoprint.util import monotonic_time, to_bytes + + +class UserManager(GroupChangeListener, object): + def __init__(self, group_manager, settings=None): + self._group_manager = group_manager + self._group_manager.register_listener(self) + + self._logger = logging.getLogger(__name__) + self._session_users_by_session = {} + self._sessionids_by_userid = {} + + if settings is None: + settings = s() + self._settings = settings + + self._login_status_listeners = [] + + def register_login_status_listener(self, listener): + self._login_status_listeners.append(listener) + + def unregister_login_status_listener(self, listener): + self._login_status_listeners.remove(listener) + + def anonymous_user_factory(self): + return AnonymousUser([self._group_manager.guest_group]) + + def api_user_factory(self): + return ApiUser([self._group_manager.admin_group, self._group_manager.user_group]) + + @property + def enabled(self): + return True + + def login_user(self, user): + self._cleanup_sessions() + + if user is None or user.is_anonymous: + return + + if isinstance(user, LocalProxy): + # noinspection PyProtectedMember + user = user._get_current_object() + + if not isinstance(user, User): + return None + + if not isinstance(user, SessionUser): + user = SessionUser(user) + + self._session_users_by_session[user.session] = user + + userid = user.get_id() + if userid not in self._sessionids_by_userid: + self._sessionids_by_userid[userid] = set() + + self._sessionids_by_userid[userid].add(user.session) + + for listener in self._login_status_listeners: + try: + listener.on_user_logged_in(user) + except Exception: + self._logger.exception( + "Error in on_user_logged_in on {!r}".format(listener), + extra={"callback": fqcn(listener)}, + ) + + self._logger.info("Logged in user: {}".format(user.get_id())) + + return user + + def logout_user(self, user, stale=False): + if user is None or user.is_anonymous: + return + + if isinstance(user, LocalProxy): + user = user._get_current_object() + + if not isinstance(user, SessionUser): + return + + userid = user.get_id() + sessionid = user.session + + if userid in self._sessionids_by_userid: + try: + self._sessionids_by_userid[userid].remove(sessionid) + except KeyError: + pass + + if sessionid in self._session_users_by_session: + try: + del self._session_users_by_session[sessionid] + except KeyError: + pass + + for listener in self._login_status_listeners: + try: + listener.on_user_logged_out(user, stale=stale) + except Exception: + self._logger.exception( + "Error in on_user_logged_out on {!r}".format(listener), + extra={"callback": fqcn(listener)}, + ) + + self._logger.info("Logged out user: {}".format(user.get_id())) + + def _cleanup_sessions(self): + for session, user in list(self._session_users_by_session.items()): + if not isinstance(user, SessionUser): + continue + if user.created + (24 * 60 * 60) < monotonic_time(): + self._logger.info( + "Cleaning up user session {} for user {}".format( + session, user.get_id() + ) + ) + self.logout_user(user, stale=True) + + @staticmethod + def create_password_hash(password, salt=None, settings=None): + if not salt: + if settings is None: + settings = s() + salt = settings.get(["accessControl", "salt"]) + if salt is None: + import string + from random import choice + + chars = string.ascii_lowercase + string.ascii_uppercase + string.digits + salt = "".join(choice(chars) for _ in range(32)) + settings.set(["accessControl", "salt"], salt) + settings.save() + + return hashlib.sha512( + to_bytes(password, encoding="utf-8", errors="replace") + to_bytes(salt) + ).hexdigest() + + def check_password(self, username, password): + user = self.find_user(username) + if not user: + return False + + hash = UserManager.create_password_hash(password, settings=self._settings) + if user.check_password(hash): + # new hash matches, correct password + return True + else: + # new hash doesn't match, but maybe the old one does, so check that! + oldHash = UserManager.create_password_hash( + password, salt="mvBUTvwzBzD3yPwvnJ4E4tXNf3CGJvvW", settings=self._settings + ) + if user.check_password(oldHash): + # old hash matches, we migrate the stored password hash to the new one and return True since it's the correct password + self.change_user_password(username, password) + return True + else: + # old hash doesn't match either, wrong password + return False + + def add_user(self, username, password, active, permissions, groups, overwrite=False): + pass + + def change_user_activation(self, username, active): + pass + + def change_user_permissions(self, username, permissions): + pass + + def add_permissions_to_user(self, username, permissions): + pass + + def remove_permissions_from_user(self, username, permissions): + pass + + def change_user_groups(self, username, groups): + pass + + def add_groups_to_user(self, username, groups): + pass + + def remove_groups_from_user(self, username, groups): + pass + + def remove_groups_from_users(self, group): + pass + + def change_user_password(self, username, password): + pass + + def get_user_setting(self, username, key): + return None + + def get_all_user_settings(self, username): + return {} + + def change_user_setting(self, username, key, value): + pass + + def change_user_settings(self, username, new_settings): + pass + + def remove_user(self, username): + if username in self._sessionids_by_userid: + sessions = self._sessionids_by_userid[username] + for session in sessions: + if session in self._session_users_by_session: + del self._session_users_by_session[session] + del self._sessionids_by_userid[username] + + def validate_user_session(self, userid, session): + if session in self._session_users_by_session: + user = self._session_users_by_session[session] + return userid == user.get_id() + + return False + + def find_user(self, userid=None, session=None): + if session is not None and session in self._session_users_by_session: + user = self._session_users_by_session[session] + if userid is None or userid == user.get_id(): + return user + + return None + + def find_sessions_for(self, matcher): + result = [] + for user in self.get_all_users(): + if matcher(user): + try: + session_ids = self._sessionids_by_userid[user.get_id()] + for session_id in session_ids: + try: + result.append(self._session_users_by_session[session_id]) + except KeyError: + # unknown session after all + continue + except KeyError: + # no session for user + pass + return result + + def get_all_users(self): + return [] + + def has_been_customized(self): + return False + + def on_group_removed(self, group): + self._logger.debug( + "Group {} got removed, removing from all users".format(group.key) + ) + self.remove_groups_from_users([group]) + + def on_group_permissions_changed(self, group, added=None, removed=None): + users = self.find_sessions_for(lambda u: group in u.groups) + for listener in self._login_status_listeners: + try: + for user in users: + listener.on_user_modified(user) + except Exception: + self._logger.exception( + "Error in on_user_modified on {!r}".format(listener), + extra={"callback": fqcn(listener)}, + ) + + def on_group_subgroups_changed(self, group, added=None, removed=None): + users = self.find_sessions_for(lambda u: group in u.groups) + for listener in self._login_status_listeners: + # noinspection PyBroadException + try: + for user in users: + listener.on_user_modified(user) + except Exception: + self._logger.exception( + "Error in on_user_modified on {!r}".format(listener), + extra={"callback": fqcn(listener)}, + ) + + def _trigger_on_user_modified(self, user): + if isinstance(user, str): + # user id + users = [] + try: + session_ids = self._sessionids_by_userid[user] + for session_id in session_ids: + try: + users.append(self._session_users_by_session[session_id]) + except KeyError: + # unknown session id + continue + except KeyError: + # no session for user + return + elif isinstance(user, User) and not isinstance(user, SessionUser): + users = self.find_sessions_for(lambda u: u.get_id() == user.get_id()) + elif isinstance(user, User): + users = [user] + else: + return + + for listener in self._login_status_listeners: + try: + for user in users: + listener.on_user_modified(user) + except Exception: + self._logger.exception( + "Error in on_user_modified on {!r}".format(listener), + extra={"callback": fqcn(listener)}, + ) + + def _trigger_on_user_removed(self, username): + for listener in self._login_status_listeners: + try: + listener.on_user_removed(username) + except Exception: + self._logger.exception( + "Error in on_user_removed on {!r}".format(listener), + extra={"callback": fqcn(listener)}, + ) + + # ~~ Deprecated methods follow + + # TODO: Remove deprecated methods in OctoPrint 1.5.0 + + @staticmethod + def createPasswordHash(*args, **kwargs): + """ + .. deprecated: 1.4.0 + + Replaced by :func:`~UserManager.create_password_hash` + """ + # we can't use the deprecated decorator here since this method is static + import warnings + + warnings.warn( + "createPasswordHash has been renamed to create_password_hash", + DeprecationWarning, + stacklevel=2, + ) + return UserManager.create_password_hash(*args, **kwargs) + + @deprecated( + "changeUserRoles has been replaced by change_user_permissions", + includedoc="Replaced by :func:`change_user_permissions`", + since="1.4.0", + ) + def changeUserRoles(self, username, roles): + user = self.find_user(username) + if user is None: + raise UnknownUser(username) + + removed_roles = set(user._roles) - set(roles) + self.removeRolesFromUser(username, removed_roles, user=user) + + added_roles = set(roles) - set(user._roles) + self.addRolesToUser(username, added_roles, user=user) + + @deprecated( + "addRolesToUser has been replaced by add_permissions_to_user", + includedoc="Replaced by :func:`add_permissions_to_user`", + since="1.4.0", + ) + def addRolesToUser(self, username, roles, user=None): + if user is None: + user = self.find_user(username) + + if user is None: + raise UnknownUser(username) + + if "admin" in roles: + self.add_groups_to_user(username, self._group_manager.admin_group) + + if "user" in roles: + self.remove_groups_from_user(username, self._group_manager.user_group) + + @deprecated( + "removeRolesFromUser has been replaced by remove_permissions_from_user", + includedoc="Replaced by :func:`remove_permissions_from_user`", + since="1.4.0", + ) + def removeRolesFromUser(self, username, roles, user=None): + if user is None: + user = self.find_user(username) + + if user is None: + raise UnknownUser(username) + + if "admin" in roles: + self.remove_groups_from_user(username, self._group_manager.admin_group) + self.remove_permissions_from_user(username, Permissions.ADMIN) + + if "user" in roles: + self.remove_groups_from_user(username, self._group_manager.user_group) + + checkPassword = deprecated( + "checkPassword has been renamed to check_password", + includedoc="Replaced by :func:`check_password`", + since="1.4.0", + )(check_password) + addUser = deprecated( + "addUser has been renamed to add_user", + includedoc="Replaced by :func:`add_user`", + since="1.4.0", + )(add_user) + changeUserActivation = deprecated( + "changeUserActivation has been renamed to change_user_activation", + includedoc="Replaced by :func:`change_user_activation`", + since="1.4.0", + )(change_user_activation) + changeUserPassword = deprecated( + "changeUserPassword has been renamed to change_user_password", + includedoc="Replaced by :func:`change_user_password`", + since="1.4.0", + )(change_user_password) + getUserSetting = deprecated( + "getUserSetting has been renamed to get_user_setting", + includedoc="Replaced by :func:`get_user_setting`", + since="1.4.0", + )(get_user_setting) + getAllUserSettings = deprecated( + "getAllUserSettings has been renamed to get_all_user_settings", + includedoc="Replaced by :func:`get_all_user_settings`", + since="1.4.0", + )(get_all_user_settings) + changeUserSetting = deprecated( + "changeUserSetting has been renamed to change_user_setting", + includedoc="Replaced by :func:`change_user_setting`", + since="1.4.0", + )(change_user_setting) + changeUserSettings = deprecated( + "changeUserSettings has been renamed to change_user_settings", + includedoc="Replaced by :func:`change_user_settings`", + since="1.4.0", + )(change_user_settings) + removeUser = deprecated( + "removeUser has been renamed to remove_user", + includedoc="Replaced by :func:`remove_user`", + since="1.4.0", + )(remove_user) + findUser = deprecated( + "findUser has been renamed to find_user", + includedoc="Replaced by :func:`find_user`", + since="1.4.0", + )(find_user) + getAllUsers = deprecated( + "getAllUsers has been renamed to get_all_users", + includedoc="Replaced by :func:`get_all_users`", + since="1.4.0", + )(get_all_users) + hasBeenCustomized = deprecated( + "hasBeenCustomized has been renamed to has_been_customized", + includedoc="Replaced by :func:`has_been_customized`", + since="1.4.0", + )(has_been_customized) + + +class LoginStatusListener(object): + def on_user_logged_in(self, user): + pass + + def on_user_logged_out(self, user, stale=False): + pass + + def on_user_modified(self, user): + pass + + def on_user_removed(self, userid): + pass + + +##~~ FilebasedUserManager, takes available users from users.yaml file + + +class FilebasedUserManager(UserManager): + def __init__(self, group_manager, path=None, settings=None): + UserManager.__init__(self, group_manager, settings=settings) + + self._logger = logging.getLogger(__name__) + + if path is None: + path = self._settings.get(["accessControl", "userfile"]) + if path is None: + path = os.path.join(s().getBaseFolder("base"), "users.yaml") + + self._userfile = path + + self._users = {} + self._dirty = False + + self._customized = None + self._load() + + def _load(self): + if os.path.exists(self._userfile) and os.path.isfile(self._userfile): + # noinspection PyBroadException + with io.open(self._userfile, "rt", encoding="utf-8") as f: + data = yaml.safe_load(f) + + if not data or not isinstance(data, dict): + self._logger.fatal( + "{} does not contain a valid map of users. Fix " + "the file, or remove it, then restart OctoPrint.".format( + self._userfile + ) + ) + raise CorruptUserStorage() + + for name, attributes in data.items(): + if not isinstance(attributes, dict): + continue + + permissions = [] + if "permissions" in attributes: + permissions = attributes["permissions"] + + if "groups" in attributes: + groups = set(attributes["groups"]) + else: + groups = {self._group_manager.user_group} + + # migrate from roles to permissions + if "roles" in attributes and "permissions" not in attributes: + self._logger.info( + "Migrating user {} to new granular permission system".format( + name + ) + ) + + groups |= set(self._migrate_roles_to_groups(attributes["roles"])) + self._dirty = True + + apikey = None + if "apikey" in attributes: + apikey = attributes["apikey"] + settings = {} + if "settings" in attributes: + settings = attributes["settings"] + + self._users[name] = User( + username=name, + passwordHash=attributes["password"], + active=attributes["active"], + permissions=self._to_permissions(*permissions), + groups=self._to_groups(*groups), + apikey=apikey, + settings=settings, + ) + for sessionid in self._sessionids_by_userid.get(name, set()): + if sessionid in self._session_users_by_session: + self._session_users_by_session[sessionid].update_user( + self._users[name] + ) + + if self._dirty: + self._save() + + self._customized = True + else: + self._customized = False + + def _save(self, force=False): + if not self._dirty and not force: + return + + data = {} + for name, user in self._users.items(): + if not user or not isinstance(user, User): + continue + + data[name] = { + "password": user._passwordHash, + "active": user._active, + "groups": self._from_groups(*user._groups), + "permissions": self._from_permissions(*user._permissions), + "apikey": user._apikey, + "settings": user._settings, + # TODO: deprecated, remove in 1.5.0 + "roles": user._roles, + } + + with atomic_write( + self._userfile, mode="wt", permissions=0o600, max_permissions=0o666 + ) as f: + yaml.safe_dump( + data, f, default_flow_style=False, indent=2, allow_unicode=True + ) + self._dirty = False + self._load() + + def _migrate_roles_to_groups(self, roles): + # If admin is inside the roles, just return admin group + if "admin" in roles: + return [self._group_manager.admin_group, self._group_manager.user_group] + else: + return [self._group_manager.user_group] + + def _refresh_groups(self, user): + user._groups = self._to_groups(*map(lambda g: g.key, user.groups)) + + def add_user( + self, + username, + password, + active=False, + permissions=None, + groups=None, + apikey=None, + overwrite=False, + ): + if not permissions: + permissions = [] + permissions = self._to_permissions(*permissions) + + if not groups: + groups = self._group_manager.default_groups + groups = self._to_groups(*groups) + + if username in self._users and not overwrite: + raise UserAlreadyExists(username) + + self._users[username] = User( + username, + UserManager.create_password_hash(password, settings=self._settings), + active, + permissions, + groups, + apikey=apikey, + ) + self._dirty = True + self._save() + + def change_user_activation(self, username, active): + if username not in self._users: + raise UnknownUser(username) + + if self._users[username].is_active != active: + self._users[username]._active = active + self._dirty = True + self._save() + + self._trigger_on_user_modified(username) + + def change_user_permissions(self, username, permissions): + if username not in self._users: + raise UnknownUser(username) + + user = self._users[username] + + permissions = self._to_permissions(*permissions) + + removed_permissions = list(set(user._permissions) - set(permissions)) + added_permissions = list(set(permissions) - set(user._permissions)) + + if len(removed_permissions) > 0: + user.remove_permissions_from_user(removed_permissions) + self._dirty = True + + if len(added_permissions) > 0: + user.add_permissions_to_user(added_permissions) + self._dirty = True + + if self._dirty: + self._save() + self._trigger_on_user_modified(username) + + def add_permissions_to_user(self, username, permissions): + if username not in self._users: + raise UnknownUser(username) + + if self._users[username].add_permissions_to_user( + self._to_permissions(*permissions) + ): + self._dirty = True + self._save() + self._trigger_on_user_modified(username) + + def remove_permissions_from_user(self, username, permissions): + if username not in self._users: + raise UnknownUser(username) + + if self._users[username].remove_permissions_from_user( + self._to_permissions(*permissions) + ): + self._dirty = True + self._save() + self._trigger_on_user_modified(username) + + def remove_permissions_from_users(self, permissions): + modified = [] + for user in self._users: + dirty = user.remove_permissions_from_user(self._to_permissions(*permissions)) + if dirty: + self._dirty = True + modified.append(user.get_id()) + + if self._dirty: + self._save() + for username in modified: + self._trigger_on_user_modified(username) + + def change_user_groups(self, username, groups): + if username not in self._users: + raise UnknownUser(username) + + user = self._users[username] + + groups = self._to_groups(*groups) + + removed_groups = list(set(user._groups) - set(groups)) + added_groups = list(set(groups) - set(user._groups)) + + if len(removed_groups): + self._dirty |= user.remove_groups_from_user(removed_groups) + if len(added_groups): + self._dirty |= user.add_groups_to_user(added_groups) + + if self._dirty: + self._save() + self._trigger_on_user_modified(username) + + def add_groups_to_user(self, username, groups, save=True, notify=True): + if username not in self._users: + raise UnknownUser(username) + + if self._users[username].add_groups_to_user(self._to_groups(*groups)): + self._dirty = True + + if save: + self._save() + + if notify: + self._trigger_on_user_modified(username) + + def remove_groups_from_user(self, username, groups, save=True, notify=True): + if username not in self._users: + raise UnknownUser(username) + + if self._users[username].remove_groups_from_user(self._to_groups(*groups)): + self._dirty = True + + if save: + self._save() + + if notify: + self._trigger_on_user_modified(username) + + def remove_groups_from_users(self, groups): + modified = [] + for username, user in self._users.items(): + dirty = user.remove_groups_from_user(self._to_groups(*groups)) + if dirty: + self._dirty = True + modified.append(username) + + if self._dirty: + self._save() + + for username in modified: + self._trigger_on_user_modified(username) + + def change_user_password(self, username, password): + if username not in self._users: + raise UnknownUser(username) + + passwordHash = UserManager.create_password_hash(password, settings=self._settings) + user = self._users[username] + if user._passwordHash != passwordHash: + user._passwordHash = passwordHash + self._dirty = True + self._save() + + def change_user_setting(self, username, key, value): + if username not in self._users: + raise UnknownUser(username) + + user = self._users[username] + old_value = user.get_setting(key) + if not old_value or old_value != value: + user.set_setting(key, value) + self._dirty = self._dirty or old_value != value + self._save() + + def change_user_settings(self, username, new_settings): + if username not in self._users: + raise UnknownUser(username) + + user = self._users[username] + for key, value in new_settings.items(): + old_value = user.get_setting(key) + user.set_setting(key, value) + self._dirty = self._dirty or old_value != value + self._save() + + def get_all_user_settings(self, username): + if username not in self._users: + raise UnknownUser(username) + + user = self._users[username] + return user.get_all_settings() + + def get_user_setting(self, username, key): + if username not in self._users: + raise UnknownUser(username) + + user = self._users[username] + return user.get_setting(key) + + def generate_api_key(self, username): + if username not in self._users: + raise UnknownUser(username) + + user = self._users[username] + user._apikey = generate_api_key() + self._dirty = True + self._save() + return user._apikey + + def delete_api_key(self, username): + if username not in self._users: + raise UnknownUser(username) + + user = self._users[username] + user._apikey = None + self._dirty = True + self._save() + + def remove_user(self, username): + UserManager.remove_user(self, username) + + if username not in self._users: + raise UnknownUser(username) + + del self._users[username] + self._dirty = True + self._save() + + def find_user(self, userid=None, apikey=None, session=None): + user = UserManager.find_user(self, userid=userid, session=session) + + if user is not None: + return user + + if userid is not None: + if userid not in self._users: + return None + return self._users[userid] + + elif apikey is not None: + for user in self._users.values(): + if apikey == user._apikey: + return user + return None + + else: + return None + + def get_all_users(self): + return list(self._users.values()) + + def has_been_customized(self): + return self._customized + + def on_group_permissions_changed(self, group, added=None, removed=None): + # refresh our group references + for user in self.get_all_users(): + if group in user.groups: + self._refresh_groups(user) + + # call parent + UserManager.on_group_permissions_changed( + self, group, added=added, removed=removed + ) + + def on_group_subgroups_changed(self, group, added=None, removed=None): + # refresh our group references + for user in self.get_all_users(): + if group in user.groups: + self._refresh_groups(user) + + # call parent + UserManager.on_group_subgroups_changed(self, group, added=added, removed=removed) + + # ~~ Helpers + + def _to_groups(self, *groups): + return list( + set( + filter( + lambda x: x is not None, + (self._group_manager._to_group(group) for group in groups), + ) + ) + ) + + def _to_permissions(self, *permissions): + return list( + set( + filter( + lambda x: x is not None, + (Permissions.find(permission) for permission in permissions), + ) + ) + ) + + def _from_groups(self, *groups): + return list({group.key for group in groups}) + + def _from_permissions(self, *permissions): + return list({permission.key for permission in permissions}) + + # ~~ Deprecated methods follow + + # TODO: Remove deprecated methods in OctoPrint 1.5.0 + + generateApiKey = deprecated( + "generateApiKey has been renamed to generate_api_key", + includedoc="Replaced by :func:`generate_api_key`", + since="1.4.0", + )(generate_api_key) + deleteApiKey = deprecated( + "deleteApiKey has been renamed to delete_api_key", + includedoc="Replaced by :func:`delete_api_key`", + since="1.4.0", + )(delete_api_key) + addUser = deprecated( + "addUser has been renamed to add_user", + includedoc="Replaced by :func:`add_user`", + since="1.4.0", + )(add_user) + changeUserActivation = deprecated( + "changeUserActivation has been renamed to change_user_activation", + includedoc="Replaced by :func:`change_user_activation`", + since="1.4.0", + )(change_user_activation) + changeUserPassword = deprecated( + "changeUserPassword has been renamed to change_user_password", + includedoc="Replaced by :func:`change_user_password`", + since="1.4.0", + )(change_user_password) + getUserSetting = deprecated( + "getUserSetting has been renamed to get_user_setting", + includedoc="Replaced by :func:`get_user_setting`", + since="1.4.0", + )(get_user_setting) + getAllUserSettings = deprecated( + "getAllUserSettings has been renamed to get_all_user_settings", + includedoc="Replaced by :func:`get_all_user_settings`", + since="1.4.0", + )(get_all_user_settings) + changeUserSetting = deprecated( + "changeUserSetting has been renamed to change_user_setting", + includedoc="Replaced by :func:`change_user_setting`", + since="1.4.0", + )(change_user_setting) + changeUserSettings = deprecated( + "changeUserSettings has been renamed to change_user_settings", + includedoc="Replaced by :func:`change_user_settings`", + since="1.4.0", + )(change_user_settings) + removeUser = deprecated( + "removeUser has been renamed to remove_user", + includedoc="Replaced by :func:`remove_user`", + since="1.4.0", + )(remove_user) + findUser = deprecated( + "findUser has been renamed to find_user", + includedoc="Replaced by :func:`find_user`", + since="1.4.0", + )(find_user) + getAllUsers = deprecated( + "getAllUsers has been renamed to get_all_users", + includedoc="Replaced by :func:`get_all_users`", + since="1.4.0", + )(get_all_users) + hasBeenCustomized = deprecated( + "hasBeenCustomized has been renamed to has_been_customized", + includedoc="Replaced by :func:`has_been_customized`", + since="1.4.0", + )(has_been_customized) + + +##~~ Exceptions + + +class UserAlreadyExists(Exception): + def __init__(self, username): + Exception.__init__(self, "User %s already exists" % username) + + +class UnknownUser(Exception): + def __init__(self, username): + Exception.__init__(self, "Unknown user: %s" % username) + + +class UnknownRole(Exception): + def __init__(self, role): + Exception.__init__(self, "Unknown role: %s" % role) + + +class CorruptUserStorage(Exception): + pass + + +##~~ Refactoring helpers + + +class MethodReplacedByBooleanProperty(object): + def __init__(self, name, message, getter): + self._name = name + self._message = message + self._getter = getter + + @property + def _attr(self): + return self._getter() + + def __call__(self): + from warnings import warn + + warn(DeprecationWarning(self._message.format(name=self._name)), stacklevel=2) + return self._attr + + def __eq__(self, other): + return self._attr == other + + def __ne__(self, other): + return self._attr != other + + def __bool__(self): + # Python 3 + return self._attr + + def __nonzero__(self): + # Python 2 + return self._attr + + def __hash__(self): + return hash(self._attr) + + def __repr__(self): + return "MethodReplacedByProperty({}, {}, {})".format( + self._name, self._message, self._getter + ) + + def __str__(self): + return str(self._attr) + + +# TODO: Remove compatibility layer in OctoPrint 1.5.0 +class FlaskLoginMethodReplacedByBooleanProperty(MethodReplacedByBooleanProperty): + def __init__(self, name, getter): + message = ( + "{name} is now a property in Flask-Login versions >= 0.3.0, which OctoPrint now uses. " + + "Use {name} instead of {name}(). This compatibility layer will be removed in OctoPrint 1.5.0." + ) + MethodReplacedByBooleanProperty.__init__(self, name, message, getter) + + +# TODO: Remove compatibility layer in OctoPrint 1.5.0 +class OctoPrintUserMethodReplacedByBooleanProperty(MethodReplacedByBooleanProperty): + def __init__(self, name, getter): + message = ( + "{name} is now a property for consistency reasons with Flask-Login versions >= 0.3.0, which " + + "OctoPrint now uses. Use {name} instead of {name}(). This compatibility layer will be removed " + + "in OctoPrint 1.5.0." + ) + MethodReplacedByBooleanProperty.__init__(self, name, message, getter) + + +##~~ User object + + +class User(UserMixin): + def __init__( + self, + username, + passwordHash, + active, + permissions=None, + groups=None, + apikey=None, + settings=None, + ): + if permissions is None: + permissions = [] + if groups is None: + groups = [] + + self._username = username + self._passwordHash = passwordHash + self._active = active + self._permissions = permissions + self._groups = groups + self._apikey = apikey + + if settings is None: + settings = {} + + self._settings = settings + + def as_dict(self): + from octoprint.access.permissions import OctoPrintPermission + + return { + "name": self._username, + "active": bool(self.is_active), + "permissions": list(map(lambda p: p.key, self._permissions)), + "groups": list(map(lambda g: g.key, self._groups)), + "needs": OctoPrintPermission.convert_needs_to_dict(self.needs), + "apikey": self._apikey, + "settings": self._settings, + # TODO: deprecated, remove in 1.5.0 + "admin": self.has_permission(Permissions.ADMIN), + "user": not self.is_anonymous, + "roles": self._roles, + } + + def check_password(self, passwordHash): + return self._passwordHash == passwordHash + + def get_id(self): + return self.get_name() + + def get_name(self): + return self._username + + @property + def is_anonymous(self): + return FlaskLoginMethodReplacedByBooleanProperty("is_anonymous", lambda: False) + + @property + def is_authenticated(self): + return FlaskLoginMethodReplacedByBooleanProperty("is_authenticated", lambda: True) + + @property + def is_active(self): + return FlaskLoginMethodReplacedByBooleanProperty( + "is_active", lambda: self._active + ) + + def get_all_settings(self): + return self._settings + + def get_setting(self, key): + if not isinstance(key, (tuple, list)): + path = [key] + else: + path = key + + return self._get_setting(path) + + def set_setting(self, key, value): + if not isinstance(key, (tuple, list)): + path = [key] + else: + path = key + return self._set_setting(path, value) + + def _get_setting(self, path): + s = self._settings + for p in path: + if isinstance(s, dict) and p in s: + s = s[p] + else: + return None + return s + + def _set_setting(self, path, value): + s = self._settings + for p in path[:-1]: + if p not in s: + s[p] = {} + + if not isinstance(s[p], dict): + s[p] = {} + + s = s[p] + + key = path[-1] + s[key] = value + return True + + def add_permissions_to_user(self, permissions): + # Make sure the permissions variable is of type list + if not isinstance(permissions, list): + permissions = [permissions] + + assert all(map(lambda p: isinstance(p, OctoPrintPermission), permissions)) + + dirty = False + for permission in permissions: + if permissions not in self._permissions: + self._permissions.append(permission) + dirty = True + + return dirty + + def remove_permissions_from_user(self, permissions): + # Make sure the permissions variable is of type list + if not isinstance(permissions, list): + permissions = [permissions] + + assert all(map(lambda p: isinstance(p, OctoPrintPermission), permissions)) + + dirty = False + for permission in permissions: + if permission in self._permissions: + self._permissions.remove(permission) + dirty = True + + return dirty + + def add_groups_to_user(self, groups): + # Make sure the groups variable is of type list + if not isinstance(groups, list): + groups = [groups] + + assert all(map(lambda p: isinstance(p, Group), groups)) + + dirty = False + for group in groups: + if group.is_toggleable() and group not in self._groups: + self._groups.append(group) + dirty = True + + return dirty + + def remove_groups_from_user(self, groups): + # Make sure the groups variable is of type list + if not isinstance(groups, list): + groups = [groups] + + assert all(map(lambda p: isinstance(p, Group), groups)) + + dirty = False + for group in groups: + if group.is_toggleable() and group in self._groups: + self._groups.remove(group) + dirty = True + + return dirty + + @property + def permissions(self): + if self._permissions is None: + return [] + + if Permissions.ADMIN in self._permissions: + return Permissions.all() + + return list(filter(lambda p: p is not None, self._permissions)) + + @property + def groups(self): + return list(self._groups) + + @property + def effective_permissions(self): + if self._permissions is None: + return [] + return list( + filter(lambda p: p is not None and self.has_permission(p), Permissions.all()) + ) + + @property + def needs(self): + needs = set() + + for permission in self.permissions: + if permission is not None: + needs = needs.union(permission.needs) + + for group in self.groups: + if group is not None: + needs = needs.union(group.needs) + + return needs + + def has_permission(self, permission): + return self.has_needs(*permission.needs) + + def has_needs(self, *needs): + return set(needs).issubset(self.needs) + + def __repr__(self): + return ( + "User(id=%s,name=%s,active=%r,user=True,admin=%r,permissions=%s,groups=%s)" + % ( + self.get_id(), + self.get_name(), + bool(self.is_active), + self.has_permission(Permissions.ADMIN), + self._permissions, + self._groups, + ) + ) + + # ~~ Deprecated methods & properties follow + + # TODO: Remove deprecated methods & properties in OctoPrint 1.5.0 + + asDict = deprecated( + "asDict has been renamed to as_dict", + includedoc="Replaced by :func:`as_dict`", + since="1.4.0", + )(as_dict) + + @property + @deprecated("is_user is deprecated, please use has_permission", since="1.4.0") + def is_user(self): + return OctoPrintUserMethodReplacedByBooleanProperty( + "is_user", lambda: not self.is_anonymous + ) + + @property + @deprecated("is_admin is deprecated, please use has_permission", since="1.4.0") + def is_admin(self): + return OctoPrintUserMethodReplacedByBooleanProperty( + "is_admin", lambda: self.has_permission(Permissions.ADMIN) + ) + + @property + @deprecated("roles is deprecated, please use has_permission", since="1.4.0") + def roles(self): + return self._roles + + @property + def _roles(self): + """Helper for the deprecated self.roles and serializing to yaml""" + if self.has_permission(Permissions.ADMIN): + return ["user", "admin"] + elif not self.is_anonymous: + return ["user"] + else: + return [] + + +class AnonymousUser(AnonymousUserMixin, User): + def __init__(self, groups): + User.__init__(self, None, "", True, [], groups) + + @property + def is_anonymous(self): + return FlaskLoginMethodReplacedByBooleanProperty("is_anonymous", lambda: True) + + @property + def is_authenticated(self): + return FlaskLoginMethodReplacedByBooleanProperty( + "is_authenticated", lambda: False + ) + + @property + def is_active(self): + return FlaskLoginMethodReplacedByBooleanProperty( + "is_active", lambda: self._active + ) + + def check_password(self, passwordHash): + return True + + def as_dict(self): + from octoprint.access.permissions import OctoPrintPermission + + return {"needs": OctoPrintPermission.convert_needs_to_dict(self.needs)} + + def __repr__(self): + return "AnonymousUser(groups=%s)" % self._groups + + +class SessionUser(wrapt.ObjectProxy): + def __init__(self, user): + wrapt.ObjectProxy.__init__(self, user) + + self._self_session = "".join("%02X" % z for z in bytes(uuid.uuid4().bytes)) + self._self_created = monotonic_time() + self._self_touched = monotonic_time() + + @property + def session(self): + return self._self_session + + @property + def created(self): + return self._self_created + + @property + def touched(self): + return self._self_touched + + def touch(self): + self._self_touched = monotonic_time() + + @deprecated( + "SessionUser.get_session() has been deprecated, use SessionUser.session instead", + since="1.3.5", + ) + def get_session(self): + return self.session + + def update_user(self, user): + self.__wrapped__ = user + + def as_dict(self): + result = self.__wrapped__.as_dict() + result.update({"session": self.session}) + return result + + def __repr__(self): + return "SessionUser({!r},session={},created={})".format( + self.__wrapped__, self.session, self.created + ) + + +##~~ User object to use when global api key is used to access the API + + +class ApiUser(User): + def __init__(self, groups): + User.__init__(self, "_api", "", True, [], groups) diff --git a/src/octoprint/cli/__init__.py b/src/octoprint/cli/__init__.py index 34ad3f30e0..b78756a0d3 100644 --- a/src/octoprint/cli/__init__.py +++ b/src/octoprint/cli/__init__.py @@ -1,175 +1,347 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" +import sys + import click + import octoprint -import sys -#~~ click context +click.disable_unicode_literals_warning = True + + +# ~~ click context + class OctoPrintContext(object): - """Custom context wrapping the standard options.""" + """Custom context wrapping the standard options.""" + + def __init__(self, configfile=None, basedir=None, verbosity=0, safe_mode=False): + self.configfile = configfile + self.basedir = basedir + self.verbosity = verbosity + self.safe_mode = safe_mode - def __init__(self, configfile=None, basedir=None, verbosity=0, safe_mode=False): - self.configfile = configfile - self.basedir = basedir - self.verbosity = verbosity - self.safe_mode = safe_mode pass_octoprint_ctx = click.make_pass_decorator(OctoPrintContext, ensure=True) """Decorator to pass in the :class:`OctoPrintContext` instance.""" -#~~ Custom click option to hide from help +# ~~ Basic CLI initialization for plugins + + +def init_platform_for_cli(ctx): + """ + Performs a basic platform initialization for the CLI. + + Plugin implementations will be initialized, but only with a subset of the usual + property injections: + + * _identifier and everything else parsed from metadata + * _logger + * _connectivity_checker + * _environment_detector + * _event_bus + * _plugin_manager + * _settings + + Returns: the same list of components as returned by ``init_platform`` + """ + + from octoprint import ( + init_custom_events, + init_platform, + init_settings_plugin_config_migration_and_cleanup, + ) + from octoprint import octoprint_plugin_inject_factory as opif + from octoprint import settings_plugin_inject_factory as spif + + components = init_platform( + get_ctx_obj_option(ctx, "basedir", None), + get_ctx_obj_option(ctx, "configfile", None), + overlays=get_ctx_obj_option(ctx, "overlays", None), + safe_mode=True, + ) + + ( + settings, + logger, + safe_mode, + event_manager, + connectivity_checker, + plugin_manager, + environment_detector, + ) = components + + init_custom_events(plugin_manager) + octoprint_plugin_inject_factory = opif( + settings, + { + "plugin_manager": plugin_manager, + "event_bus": event_manager, + "connectivity_checker": connectivity_checker, + "environment_detector": environment_detector, + }, + ) + settings_plugin_inject_factory = spif(settings) + + plugin_manager.implementation_inject_factories = [ + octoprint_plugin_inject_factory, + settings_plugin_inject_factory, + ] + plugin_manager.initialize_implementations() + + init_settings_plugin_config_migration_and_cleanup(plugin_manager) + + return components + + +# ~~ Custom click option to hide from help + class HiddenOption(click.Option): - """Custom option sub class with empty help.""" - def get_help_record(self, ctx): - pass + """Custom option sub class with empty help.""" + + def get_help_record(self, ctx): + pass + def hidden_option(*param_decls, **attrs): - """Attaches a hidden option to the command. All positional arguments are - passed as parameter declarations to :class:`Option`; all keyword - arguments are forwarded unchanged. This is equivalent to creating an - :class:`Option` instance manually and attaching it to the - :attr:`Command.params` list. - """ + """Attaches a hidden option to the command. All positional arguments are + passed as parameter declarations to :class:`Option`; all keyword + arguments are forwarded unchanged. This is equivalent to creating an + :class:`Option` instance manually and attaching it to the + :attr:`Command.params` list. + """ + + import inspect - import inspect - from click.decorators import _param_memo + from click.decorators import _param_memo - def decorator(f): - if 'help' in attrs: - attrs['help'] = inspect.cleandoc(attrs['help']) - _param_memo(f, HiddenOption(param_decls, **attrs)) - return f - return decorator + def decorator(f): + if "help" in attrs: + attrs["help"] = inspect.cleandoc(attrs["help"]) + _param_memo(f, HiddenOption(param_decls, **attrs)) + return f + + return decorator + + +# ~~ helper for setting context options -#~~ helper for setting context options def set_ctx_obj_option(ctx, param, value): - """Helper for setting eager options on the context.""" - if ctx.obj is None: - ctx.obj = OctoPrintContext() - if value != param.default: - setattr(ctx.obj, param.name, value) - elif param.default is not None: - setattr(ctx.obj, param.name, param.default) + """Helper for setting eager options on the context.""" + if ctx.obj is None: + ctx.obj = OctoPrintContext() + if value != param.default: + setattr(ctx.obj, param.name, value) + elif param.default is not None: + setattr(ctx.obj, param.name, param.default) + + +# ~~ helper for retrieving context options -#~~ helper for retrieving context options def get_ctx_obj_option(ctx, key, default, include_parents=True): - if include_parents and hasattr(ctx, "parent") and ctx.parent: - fallback = get_ctx_obj_option(ctx.parent, key, default) - else: - fallback = default - return getattr(ctx.obj, key, fallback) + if include_parents and hasattr(ctx, "parent") and ctx.parent: + fallback = get_ctx_obj_option(ctx.parent, key, default) + else: + fallback = default + return getattr(ctx.obj, key, fallback) + + +# ~~ helper for setting a lot of bulk options -#~~ helper for setting a lot of bulk options def bulk_options(options): - """ - Utility decorator to decorate a function with a list of click decorators. + """ + Utility decorator to decorate a function with a list of click decorators. + + The provided list of ``options`` will be reversed to ensure correct + processing order (inverse from what would be intuitive). + """ + + def decorator(f): + options.reverse() + for option in options: + option(f) + return f - The provided list of ``options`` will be reversed to ensure correct - processing order (inverse from what would be intuitive). - """ + return decorator - def decorator(f): - options.reverse() - for option in options: - option(f) - return f - return decorator -#~~ helper for setting --basedir, --config and --verbose options +# ~~ helper for setting --basedir, --config and --verbose options + def standard_options(hidden=False): - """ - Decorator to add the standard options shared among all "octoprint" commands. - - Adds the options ``--basedir``, ``--config`` and ``--verbose``. If ``hidden`` - is set to ``True``, the options will be available on the command but not - listed in its help page. - """ - - factory = click.option - if hidden: - factory = hidden_option - - options = [ - factory("--basedir", "-b", type=click.Path(), callback=set_ctx_obj_option, is_eager=True, expose_value=False, - help="Specify the basedir to use for configs, uploads, timelapses etc."), - factory("--config", "-c", "configfile", type=click.Path(), callback=set_ctx_obj_option, is_eager=True, expose_value=False, - help="Specify the config file to use."), - factory("--verbose", "-v", "verbosity", count=True, callback=set_ctx_obj_option, is_eager=True, expose_value=False, - help="Increase logging verbosity."), - factory("--safe", "safe_mode", is_flag=True, callback=set_ctx_obj_option, is_eager=True, expose_value=False, - help="Enable safe mode; disables all third party plugins.") - ] - - return bulk_options(options) - -#~~ helper for settings legacy options we still have to support on "octoprint" - -legacy_options = bulk_options([ - hidden_option("--host", type=click.STRING, callback=set_ctx_obj_option), - hidden_option("--port", type=click.INT, callback=set_ctx_obj_option), - hidden_option("--logging", type=click.Path(), callback=set_ctx_obj_option), - hidden_option("--debug", "-d", is_flag=True, callback=set_ctx_obj_option), - hidden_option("--daemon", type=click.Choice(["start", "stop", "restart"]), callback=set_ctx_obj_option), - hidden_option("--pid", type=click.Path(), default="/tmp/octoprint.pid", callback=set_ctx_obj_option), - hidden_option("--iknowwhatimdoing", "allow_root", is_flag=True, callback=set_ctx_obj_option), - hidden_option("--ignore-blacklist", "ignore_blacklist", is_flag=True, callback=set_ctx_obj_option) -]) + """ + Decorator to add the standard options shared among all "octoprint" commands. + + If ``hidden`` is set to ``True``, the options will be available on the command but not + listed in its help page. + """ + + factory = click.option + if hidden: + factory = hidden_option + + options = [ + factory( + "--basedir", + "-b", + type=click.Path(), + callback=set_ctx_obj_option, + is_eager=True, + expose_value=False, + help="Specify the basedir to use for configs, uploads, timelapses etc.", + ), + factory( + "--config", + "-c", + "configfile", + type=click.Path(), + callback=set_ctx_obj_option, + is_eager=True, + expose_value=False, + help="Specify the config file to use.", + ), + factory( + "--overlay", + "overlays", + type=click.Path(), + callback=set_ctx_obj_option, + is_eager=True, + multiple=True, + expose_value=False, + help="Specify additional config overlays to use.", + ), + factory( + "--verbose", + "-v", + "verbosity", + count=True, + callback=set_ctx_obj_option, + is_eager=True, + expose_value=False, + help="Increase logging verbosity.", + ), + factory( + "--safe", + "safe_mode", + is_flag=True, + callback=set_ctx_obj_option, + is_eager=True, + expose_value=False, + help="Enable safe mode; disables all third party plugins.", + ), + ] + + return bulk_options(options) + + +# ~~ helper for settings legacy options we still have to support on "octoprint" + +legacy_options = bulk_options( + [ + hidden_option("--host", type=click.STRING, callback=set_ctx_obj_option), + hidden_option("--port", type=click.INT, callback=set_ctx_obj_option), + hidden_option("--logging", type=click.Path(), callback=set_ctx_obj_option), + hidden_option("--debug", "-d", is_flag=True, callback=set_ctx_obj_option), + hidden_option( + "--daemon", + type=click.Choice(["start", "stop", "restart"]), + callback=set_ctx_obj_option, + ), + hidden_option( + "--pid", + type=click.Path(), + default="/tmp/octoprint.pid", + callback=set_ctx_obj_option, + ), + hidden_option( + "--iknowwhatimdoing", "allow_root", is_flag=True, callback=set_ctx_obj_option + ), + hidden_option( + "--ignore-blacklist", + "ignore_blacklist", + is_flag=True, + callback=set_ctx_obj_option, + ), + ] +) """Legacy options available directly on the "octoprint" command in earlier versions. Kept available for reasons of backwards compatibility, but hidden from the generated help pages.""" -#~~ "octoprint" command, merges server_commands and plugin_commands groups - -from .server import server_commands -from .plugins import plugin_commands -from .dev import dev_commands -from .client import client_commands -from .config import config_commands -from .analysis import analysis_commands - -@click.group(name="octoprint", invoke_without_command=True, cls=click.CommandCollection, - sources=[server_commands, plugin_commands, dev_commands, client_commands, config_commands, analysis_commands]) +# ~~ "octoprint" command, merges server_commands and plugin_commands groups + +from .analysis import analysis_commands # noqa: E402 +from .client import client_commands # noqa: E402 +from .config import config_commands # noqa: E402 +from .dev import dev_commands # noqa: E402 +from .plugins import plugin_commands # noqa: E402 +from .server import server_commands # noqa: E402 +from .systeminfo import systeminfo_commands # noqa: E402 +from .user import user_commands # noqa: E402 + + +@click.group( + name="octoprint", + invoke_without_command=True, + cls=click.CommandCollection, + sources=[ + server_commands, + plugin_commands, + dev_commands, + client_commands, + config_commands, + analysis_commands, + user_commands, + systeminfo_commands, + ], +) @standard_options() @legacy_options @click.version_option(version=octoprint.__version__, allow_from_autoenv=False) @click.pass_context def octo(ctx, **kwargs): - if ctx.invoked_subcommand is None: - # We have to support calling the octoprint command without any - # sub commands to remain backwards compatible. - # - # But better print a message to inform people that they should - # use the sub commands instead. - - def get_value(key): - return get_ctx_obj_option(ctx, key, kwargs.get(key)) - daemon = get_value("daemon") - - if daemon: - click.echo("Daemon operation via \"octoprint --daemon " - "start|stop|restart\" is deprecated, please use " - "\"octoprint daemon start|stop|restart\" from now on") - - if sys.platform == "win32" or sys.platform == "darwin": - click.echo("Sorry, daemon mode is not supported under your operating system right now") - else: - from octoprint.cli.server import daemon_command - ctx.invoke(daemon_command, command=daemon, **kwargs) - else: - click.echo("Starting the server via \"octoprint\" is deprecated, " - "please use \"octoprint serve\" from now on.") - - from octoprint.cli.server import serve_command - ctx.invoke(serve_command, **kwargs) + if ctx.invoked_subcommand is None: + # We have to support calling the octoprint command without any + # sub commands to remain backwards compatible. + # + # But better print a message to inform people that they should + # use the sub commands instead. + + def get_value(key): + return get_ctx_obj_option(ctx, key, kwargs.get(key)) + + daemon = get_value("daemon") + + if daemon: + click.echo( + 'Daemon operation via "octoprint --daemon ' + 'start|stop|restart" is deprecated, please use ' + '"octoprint daemon start|stop|restart" from now on' + ) + + if sys.platform == "win32" or sys.platform == "darwin": + click.echo( + "Sorry, daemon mode is not supported under your operating system right now" + ) + else: + from octoprint.cli.server import daemon_command + + ctx.invoke(daemon_command, command=daemon, **kwargs) + else: + click.echo( + 'Starting the server via "octoprint" is deprecated, ' + 'please use "octoprint serve" from now on.' + ) + + from octoprint.cli.server import serve_command + + ctx.invoke(serve_command, **kwargs) diff --git a/src/octoprint/cli/analysis.py b/src/octoprint/cli/analysis.py index 34c90d78dc..31b8e55254 100644 --- a/src/octoprint/cli/analysis.py +++ b/src/octoprint/cli/analysis.py @@ -1,21 +1,68 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2017 The OctoPrint Project - Released under terms of the AGPLv3 License" +import sys + import click -#~~ "octoprint util" commands +click.disable_unicode_literals_warning = True + +# ~~ "octoprint util" commands + + +dimensions = ("depth", "height", "width") +printing_area = ("maxX", "maxY", "maxZ", "minX", "minY", "minZ") + + +def empty_result(result): + dims = result.get("dimensions", {}) + return all(map(lambda x: dims.get(x) == 0.0, dimensions)) + + +def validate_result(result): + def validate_list(data): + return not any(map(invalid_float, data)) + + def validate_dict(data, keys): + for k in keys: + if k not in data or invalid_float(data[k]): + return False + return True + + def invalid_float(value): + return value is None or value == float("inf") or value == float("-inf") + + if "dimensions" not in result or not validate_dict(result["dimensions"], dimensions): + return False + + if "extrusion_length" not in result or not validate_list(result["extrusion_length"]): + return False + + if "extrusion_volume" not in result or not validate_list(result["extrusion_volume"]): + return False + + if "printing_area" not in result or not validate_dict( + result["printing_area"], printing_area + ): + return False + + if "total_time" not in result or invalid_float(result["total_time"]): + return False + + return True + @click.group() def analysis_commands(): - pass + pass + @analysis_commands.group(name="analysis") def util(): - """Analysis tools.""" - pass + """Analysis tools.""" + pass @util.command(name="gcode") @@ -27,47 +74,96 @@ def util(): @click.option("--offset", "offset", type=(float, float), multiple=True) @click.option("--max-t", "maxt", type=int, default=10) @click.option("--g90-extruder", "g90_extruder", is_flag=True) +@click.option("--bed-z", "bedz", type=float, default=0) @click.option("--progress", "progress", is_flag=True) +@click.option("--layers", "layers", is_flag=True) @click.argument("path", type=click.Path()) -def gcode_command(path, speedx, speedy, speedz, offset, maxt, throttle, throttle_lines, g90_extruder, progress): - """Runs a GCODE file analysis.""" - - import time - import yaml - from octoprint.util.gcodeInterpreter import gcode - - throttle_callback = None - if throttle: - def throttle_callback(filePos, readBytes): - if filePos % throttle_lines == 0: - # only apply throttle every $throttle_lines lines - time.sleep(throttle) - - offsets = offset - if offsets is None: - offsets = [] - elif isinstance(offset, tuple): - offsets = list(offsets) - offsets = [(0, 0)] + offsets - if len(offsets) < maxt: - offsets += [(0, 0)] * (maxt - len(offsets)) - - start_time = time.time() - - progress_callback = None - if progress: - def progress_callback(percentage): - click.echo("PROGRESS:{}".format(percentage)) - interpreter = gcode(progress_callback=progress_callback) - - interpreter.load(path, - speedx=speedx, - speedy=speedy, - offsets=offsets, - throttle=throttle_callback, - max_extruders=maxt, - g90_extruder=g90_extruder) - - click.echo("DONE:{}s".format(time.time() - start_time)) - click.echo("RESULTS:") - click.echo(yaml.safe_dump(interpreter.get_result(), default_flow_style=False, indent=" ", allow_unicode=True)) +def gcode_command( + path, + speedx, + speedy, + speedz, + offset, + maxt, + throttle, + throttle_lines, + g90_extruder, + bedz, + progress, + layers, +): + """Runs a GCODE file analysis.""" + + import time + + import yaml + + from octoprint.util import monotonic_time + from octoprint.util.gcodeInterpreter import gcode + + throttle_callback = None + if throttle: + + def throttle_callback(filePos, readBytes): + if filePos % throttle_lines == 0: + # only apply throttle every $throttle_lines lines + time.sleep(throttle) + + offsets = offset + if offsets is None: + offsets = [] + elif isinstance(offset, tuple): + offsets = list(offsets) + offsets = [(0, 0)] + offsets + if len(offsets) < maxt: + offsets += [(0, 0)] * (maxt - len(offsets)) + + start_time = monotonic_time() + + progress_callback = None + if progress: + + def progress_callback(percentage): + click.echo("PROGRESS:{}".format(percentage)) + + interpreter = gcode(progress_callback=progress_callback, incl_layers=layers) + + interpreter.load( + path, + speedx=speedx, + speedy=speedy, + offsets=offsets, + throttle=throttle_callback, + max_extruders=maxt, + g90_extruder=g90_extruder, + bed_z=bedz, + ) + + click.echo("DONE:{}s".format(monotonic_time() - start_time)) + + result = interpreter.get_result() + if empty_result(result): + click.echo("EMPTY:There are no extrusions in the file, nothing to analyse") + sys.exit(0) + + if not validate_result(result): + click.echo( + "ERROR:Invalid analysis result, please create a bug report in OctoPrint's " + "issue tracker and be sure to also include the GCODE file with which this " + "happened" + ) + sys.exit(-1) + + click.echo("RESULTS:") + click.echo( + yaml.safe_dump( + interpreter.get_result(), + default_flow_style=False, + indent=2, + allow_unicode=True, + ) + ) + + +if __name__ == "__main__": + gcode_command() diff --git a/src/octoprint/cli/client.py b/src/octoprint/cli/client.py index b40f710c3f..5e4329680f 100644 --- a/src/octoprint/cli/client.py +++ b/src/octoprint/cli/client.py @@ -1,103 +1,124 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +from __future__ import absolute_import, division, print_function, unicode_literals + +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" -import click +import io import json +import click + import octoprint_client +from octoprint import FatalStartupError, init_settings +from octoprint.cli import bulk_options, get_ctx_obj_option -from octoprint.cli import get_ctx_obj_option, bulk_options -from octoprint import init_settings, FatalStartupError +click.disable_unicode_literals_warning = True class JsonStringParamType(click.ParamType): - name = "json" - - def convert(self, value, param, ctx): - try: - return json.loads(value) - except: - self.fail("%s is not a valid json string" % value, param, ctx) - - -def create_client(settings=None, apikey=None, host=None, port=None, httpuser=None, httppass=None, https=False, prefix=None): - assert(host is not None or settings is not None) - assert(port is not None or settings is not None) - assert(apikey is not None or settings is not None) - - if not host: - host = settings.get(["server", "host"]) - host = host if host != "0.0.0.0" else "127.0.0.1" - if not port: - port = settings.getInt(["server", "port"]) - - if not apikey: - apikey = settings.get(["api", "key"]) - - baseurl = octoprint_client.build_base_url(https=https, - httpuser=httpuser, - httppass=httppass, - host=host, - port=port, - prefix=prefix) - - return octoprint_client.Client(baseurl, apikey) - - -client_options = bulk_options([ - click.option("--apikey", "-a", type=click.STRING), - click.option("--host", "-h", type=click.STRING), - click.option("--port", "-p", type=click.INT), - click.option("--httpuser", type=click.STRING), - click.option("--httppass", type=click.STRING), - click.option("--https", is_flag=True), - click.option("--prefix", type=click.STRING) -]) + name = "json" + + def convert(self, value, param, ctx): + try: + return json.loads(value) + except Exception: + self.fail("%s is not a valid json string" % value, param, ctx) + + +def create_client( + settings=None, + apikey=None, + host=None, + port=None, + httpuser=None, + httppass=None, + https=False, + prefix=None, +): + assert host is not None or settings is not None + assert port is not None or settings is not None + assert apikey is not None or settings is not None + + if not host: + host = settings.get(["server", "host"]) + host = host if host != "0.0.0.0" else "127.0.0.1" + if not port: + port = settings.getInt(["server", "port"]) + + if not apikey: + apikey = settings.get(["api", "key"]) + + baseurl = octoprint_client.build_base_url( + https=https, + httpuser=httpuser, + httppass=httppass, + host=host, + port=port, + prefix=prefix, + ) + + return octoprint_client.Client(baseurl, apikey) + + +client_options = bulk_options( + [ + click.option("--apikey", "-a", type=click.STRING), + click.option("--host", "-h", type=click.STRING), + click.option("--port", "-p", type=click.INT), + click.option("--httpuser", type=click.STRING), + click.option("--httppass", type=click.STRING), + click.option("--https", is_flag=True), + click.option("--prefix", type=click.STRING), + ] +) """Common options to configure an API client.""" @click.group() def client_commands(): - pass + pass -@client_commands.group("client", context_settings=dict(ignore_unknown_options=True)) +@client_commands.group("client", context_settings={"ignore_unknown_options": True}) @client_options @click.pass_context def client(ctx, apikey, host, port, httpuser, httppass, https, prefix): - """Basic API client.""" - try: - settings = None - if not host or not port or not apikey: - settings = init_settings(get_ctx_obj_option(ctx, "basedir", None), get_ctx_obj_option(ctx, "configfile", None)) - - ctx.obj.client = create_client(settings=settings, - apikey=apikey, - host=host, - port=port, - httpuser=httpuser, - httppass=httppass, - https=https, - prefix=prefix) - - except FatalStartupError as e: - click.echo(e.message, err=True) - click.echo("There was a fatal error initializing the client.", err=True) - ctx.exit(-1) + """Basic API client.""" + try: + settings = None + if not host or not port or not apikey: + settings = init_settings( + get_ctx_obj_option(ctx, "basedir", None), + get_ctx_obj_option(ctx, "configfile", None), + ) + + ctx.obj.client = create_client( + settings=settings, + apikey=apikey, + host=host, + port=port, + httpuser=httpuser, + httppass=httppass, + https=https, + prefix=prefix, + ) + + except FatalStartupError as e: + click.echo(str(e), err=True) + click.echo("There was a fatal error initializing the client.", err=True) + ctx.exit(-1) def log_response(response, status_code=True, body=True, headers=False): - if status_code: - click.echo("Status Code: {}".format(response.status_code)) - if headers: - for header, value in response.headers.items(): - click.echo("{}: {}".format(header, value)) - click.echo() - if body: - click.echo(response.text) + if status_code: + click.echo("Status Code: {}".format(response.status_code)) + if headers: + for header, value in response.headers.items(): + click.echo("{}: {}".format(header, value)) + click.echo() + if body: + click.echo(response.text) @client.command("get") @@ -105,9 +126,9 @@ def log_response(response, status_code=True, body=True, headers=False): @click.option("--timeout", type=float, default=None) @click.pass_context def get(ctx, path, timeout): - """Performs a GET request against the specified server path.""" - r = ctx.obj.client.get(path, timeout=timeout) - log_response(r) + """Performs a GET request against the specified server path.""" + r = ctx.obj.client.get(path, timeout=timeout) + log_response(r) @client.command("post_json") @@ -116,9 +137,9 @@ def get(ctx, path, timeout): @click.option("--timeout", type=float, default=None) @click.pass_context def post_json(ctx, path, data, timeout): - """POSTs JSON data to the specified server path.""" - r = ctx.obj.client.post_json(path, data, timeout=timeout) - log_response(r) + """POSTs JSON data to the specified server path.""" + r = ctx.obj.client.post_json(path, data, timeout=timeout) + log_response(r) @client.command("patch_json") @@ -127,75 +148,118 @@ def post_json(ctx, path, data, timeout): @click.option("--timeout", type=float, default=None, help="Request timeout in seconds") @click.pass_context def patch_json(ctx, path, data, timeout): - """PATCHes JSON data to the specified server path.""" - r = ctx.obj.client.patch(path, data, encoding="json", timeout=timeout) - log_response(r) + """PATCHes JSON data to the specified server path.""" + r = ctx.obj.client.patch(path, data, encoding="json", timeout=timeout) + log_response(r) @client.command("post_from_file") @click.argument("path") -@click.argument("file_path", type=click.Path(exists=True, dir_okay=False, resolve_path=True)) +@click.argument( + "file_path", type=click.Path(exists=True, dir_okay=False, resolve_path=True) +) @click.option("--json", is_flag=True) @click.option("--yaml", is_flag=True) @click.option("--timeout", type=float, default=None, help="Request timeout in seconds") @click.pass_context def post_from_file(ctx, path, file_path, json_flag, yaml_flag, timeout): - """POSTs JSON data to the specified server path, taking the data from the specified file.""" - if json_flag or yaml_flag: - if json_flag: - with open(file_path, "rb") as fp: - data = json.load(fp) - else: - import yaml - with open(file_path, "rb") as fp: - data = yaml.safe_load(fp) + """POSTs JSON data to the specified server path, taking the data from the specified file.""" + if json_flag or yaml_flag: + if json_flag: + with io.open(file_path, "rt") as fp: + data = json.load(fp) + else: + import yaml + + with io.open(file_path, "rt") as fp: + data = yaml.safe_load(fp) - r = ctx.obj.client.post_json(path, data, timeout=timeout) - else: - with open(file_path, "rb") as fp: - data = fp.read() + r = ctx.obj.client.post_json(path, data, timeout=timeout) + else: + with io.open(file_path, "rb") as fp: + data = fp.read() - r = ctx.obj.client.post(path, data, timeout=timeout) + r = ctx.obj.client.post(path, data, timeout=timeout) - log_response(r) + log_response(r) @client.command("command") @click.argument("path") @click.argument("command") -@click.option("--str", "-s", "str_params", multiple=True, nargs=2, type=click.Tuple([unicode, unicode])) -@click.option("--int", "-i", "int_params", multiple=True, nargs=2, type=click.Tuple([unicode, int])) -@click.option("--float", "-f", "float_params", multiple=True, nargs=2, type=click.Tuple([unicode, float])) -@click.option("--bool", "-b", "bool_params", multiple=True, nargs=2, type=click.Tuple([unicode, bool])) +@click.option( + "--str", + "-s", + "str_params", + multiple=True, + nargs=2, + type=click.Tuple([str, str]), +) +@click.option( + "--int", "-i", "int_params", multiple=True, nargs=2, type=click.Tuple([str, int]) +) +@click.option( + "--float", + "-f", + "float_params", + multiple=True, + nargs=2, + type=click.Tuple([str, float]), +) +@click.option( + "--bool", + "-b", + "bool_params", + multiple=True, + nargs=2, + type=click.Tuple([str, bool]), +) @click.option("--timeout", type=float, default=None, help="Request timeout in seconds") @click.pass_context -def command(ctx, path, command, str_params, int_params, float_params, bool_params, timeout): - """Sends a JSON command to the specified server path.""" - data = dict() - params = str_params + int_params + float_params + bool_params - for param in params: - data[param[0]] = param[1] - r = ctx.obj.client.post_command(path, command, additional=data, timeout=timeout) - log_response(r, body=False) +def command( + ctx, path, command, str_params, int_params, float_params, bool_params, timeout +): + """Sends a JSON command to the specified server path.""" + data = {} + params = str_params + int_params + float_params + bool_params + for param in params: + data[param[0]] = param[1] + r = ctx.obj.client.post_command(path, command, additional=data, timeout=timeout) + log_response(r, body=False) @client.command("upload") @click.argument("path") -@click.argument("file_path", type=click.Path(exists=True, dir_okay=False, resolve_path=True)) -@click.option("--parameter", "-P", "params", multiple=True, nargs=2, type=click.Tuple([unicode, unicode])) +@click.argument( + "file_path", type=click.Path(exists=True, dir_okay=False, resolve_path=True) +) +@click.option( + "--parameter", + "-P", + "params", + multiple=True, + nargs=2, + type=click.Tuple([str, str]), +) @click.option("--file-name", type=click.STRING) @click.option("--content-type", type=click.STRING) @click.option("--timeout", type=float, default=None, help="Request timeout in seconds") @click.pass_context def upload(ctx, path, file_path, params, file_name, content_type, timeout): - """Uploads the specified file to the specified server path.""" - data = dict() - for param in params: - data[param[0]] = param[1] - - r = ctx.obj.client.upload(path, file_path, - additional=data, file_name=file_name, content_type=content_type, timeout=timeout) - log_response(r) + """Uploads the specified file to the specified server path.""" + data = {} + for param in params: + data[param[0]] = param[1] + + r = ctx.obj.client.upload( + path, + file_path, + additional=data, + file_name=file_name, + content_type=content_type, + timeout=timeout, + ) + log_response(r) @client.command("delete") @@ -203,37 +267,45 @@ def upload(ctx, path, file_path, params, file_name, content_type, timeout): @click.option("--timeout", type=float, default=None, help="Request timeout in seconds") @click.pass_context def delete(ctx, path, timeout): - """Sends a DELETE request to the specified server path.""" - r = ctx.obj.client.delete(path, timeout=timeout) - log_response(r) + """Sends a DELETE request to the specified server path.""" + r = ctx.obj.client.delete(path, timeout=timeout) + log_response(r) @client.command("listen") @click.pass_context def listen(ctx): - def on_connect(ws): - click.echo(">>> Connected!") - - def on_close(ws): - click.echo(">>> Connection closed!") - - def on_error(ws, error): - click.echo("!!! Error: {}".format(error)) - - def on_heartbeat(ws): - click.echo("<3") - - def on_message(ws, message_type, message_payload): - click.echo("Message: {}, Payload: {}".format(message_type, json.dumps(message_payload))) - - socket = ctx.obj.client.create_socket(on_connect=on_connect, - on_close=on_close, - on_error=on_error, - on_heartbeat=on_heartbeat, - on_message=on_message) - - click.echo(">>> Waiting for client to exit") - try: - socket.wait() - finally: - click.echo(">>> Goodbye...") + def on_connect(ws): + click.echo("--- Connected!") + + def on_close(ws): + click.echo("--- Connection closed!") + + def on_error(ws, error): + click.echo("!!! Error: {}".format(error)) + + def on_sent(ws, data): + click.echo(">>> {}".format(json.dumps(data))) + + def on_heartbeat(ws): + click.echo("<3") + + def on_message(ws, message_type, message_payload): + click.echo( + "<<< {}, Payload: {}".format(message_type, json.dumps(message_payload)) + ) + + socket = ctx.obj.client.create_socket( + on_connect=on_connect, + on_close=on_close, + on_error=on_error, + on_sent=on_sent, + on_heartbeat=on_heartbeat, + on_message=on_message, + ) + + click.echo("--- Waiting for client to exit") + try: + socket.wait() + finally: + click.echo("--- Goodbye...") diff --git a/src/octoprint/cli/config.py b/src/octoprint/cli/config.py index 744cfee43c..17cd5fe619 100644 --- a/src/octoprint/cli/config.py +++ b/src/octoprint/cli/config.py @@ -1,94 +1,104 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" -import click +import json import logging +import pprint -from octoprint import init_settings, FatalStartupError -from octoprint.cli import standard_options, bulk_options, get_ctx_obj_option - +import click import yaml -import json -import pprint + +from octoprint import FatalStartupError, init_settings +from octoprint.cli import get_ctx_obj_option, standard_options + +click.disable_unicode_literals_warning = True + def _to_settings_path(path): - if not isinstance(path, (list, tuple)): - path = filter(lambda x: x, map(lambda x: x.strip(), path.split("."))) - return path + if not isinstance(path, (list, tuple)): + path = list(filter(lambda x: x, map(lambda x: x.strip(), path.split(".")))) + return path + def _set_helper(settings, path, value, data_type=None): - path = _to_settings_path(path) + path = _to_settings_path(path) - method = settings.set - if data_type is not None: - name = None - if data_type == bool: - name = "setBoolean" - elif data_type == float: - name = "setFloat" - elif data_type == int: - name = "setInt" + method = settings.set + if data_type is not None: + name = None + if data_type == bool: + name = "setBoolean" + elif data_type == float: + name = "setFloat" + elif data_type == int: + name = "setInt" - if name is not None: - method = getattr(settings, name) + if name is not None: + method = getattr(settings, name) - method(path, value) - settings.save() + method(path, value, force=True) + settings.save() + + +# ~~ "octoprint config" commands -#~~ "octoprint config" commands @click.group() def config_commands(): - pass + pass + @config_commands.group(name="config") @click.pass_context def config(ctx): - """Basic config manipulation.""" - logging.basicConfig(level=logging.DEBUG if get_ctx_obj_option(ctx, "verbosity", 0) > 0 else logging.WARN) - try: - ctx.obj.settings = init_settings(get_ctx_obj_option(ctx, "basedir", None), get_ctx_obj_option(ctx, "configfile", None)) - except FatalStartupError as e: - click.echo(e.message, err=True) - click.echo("There was a fatal error initializing the settings manager.", err=True) - ctx.exit(-1) + """Basic config manipulation.""" + logging.basicConfig( + level=logging.DEBUG + if get_ctx_obj_option(ctx, "verbosity", 0) > 0 + else logging.WARN + ) + try: + ctx.obj.settings = init_settings( + get_ctx_obj_option(ctx, "basedir", None), + get_ctx_obj_option(ctx, "configfile", None), + overlays=get_ctx_obj_option(ctx, "overlays", None), + ) + except FatalStartupError as e: + click.echo(str(e), err=True) + click.echo("There was a fatal error initializing the settings manager.", err=True) + ctx.exit(-1) @config.command(name="set") @standard_options(hidden=True) @click.argument("path", type=click.STRING) @click.argument("value", type=click.STRING) -@click.option("--bool", "as_bool", is_flag=True, - help="Interpret value as bool") -@click.option("--float", "as_float", is_flag=True, - help="Interpret value as float") -@click.option("--int", "as_int", is_flag=True, - help="Interpret value as int") -@click.option("--json", "as_json", is_flag=True, - help="Parse value from json") +@click.option("--bool", "as_bool", is_flag=True, help="Interpret value as bool") +@click.option("--float", "as_float", is_flag=True, help="Interpret value as float") +@click.option("--int", "as_int", is_flag=True, help="Interpret value as int") +@click.option("--json", "as_json", is_flag=True, help="Parse value from json") @click.pass_context def set_command(ctx, path, value, as_bool, as_float, as_int, as_json): - """Sets a config path to the provided value.""" - if as_json: - try: - value = json.loads(value) - except Exception as e: - click.echo(e.message, err=True) - ctx.exit(-1) + """Sets a config path to the provided value.""" + if as_json: + try: + value = json.loads(value) + except Exception as e: + click.echo(str(e), err=True) + ctx.exit(-1) - data_type = None - if as_bool: - data_type = bool - elif as_float: - data_type = float - elif as_int: - data_type = int + data_type = None + if as_bool: + data_type = bool + elif as_float: + data_type = float + elif as_int: + data_type = int - _set_helper(ctx.obj.settings, path, value, data_type=data_type) + _set_helper(ctx.obj.settings, path, value, data_type=data_type) @config.command(name="remove") @@ -96,8 +106,8 @@ def set_command(ctx, path, value, as_bool, as_float, as_int, as_json): @click.argument("path", type=click.STRING) @click.pass_context def remove_command(ctx, path): - """Removes a config path.""" - _set_helper(ctx.obj.settings, path, None) + """Removes a config path.""" + _set_helper(ctx.obj.settings, path, None) @config.command(name="append_value") @@ -107,25 +117,25 @@ def remove_command(ctx, path): @click.option("--json", "as_json", is_flag=True) @click.pass_context def append_value_command(ctx, path, value, as_json=False): - """Appends value to list behind config path.""" - path = _to_settings_path(path) + """Appends value to list behind config path.""" + path = _to_settings_path(path) - if as_json: - try: - value = json.loads(value) - except Exception as e: - click.echo(e.message, err=True) - ctx.exit(-1) + if as_json: + try: + value = json.loads(value) + except Exception as e: + click.echo(str(e), err=True) + ctx.exit(-1) - current = ctx.obj.settings.get(path) - if current is None: - current = [] - if not isinstance(current, list): - click.echo("Cannot append to non-list value at given path", err=True) - ctx.exit(-1) + current = ctx.obj.settings.get(path) + if current is None: + current = [] + if not isinstance(current, list): + click.echo("Cannot append to non-list value at given path", err=True) + ctx.exit(-1) - current.append(value) - _set_helper(ctx.obj.settings, path, current) + current.append(value) + _set_helper(ctx.obj.settings, path, current) @config.command(name="insert_value") @@ -136,25 +146,25 @@ def append_value_command(ctx, path, value, as_json=False): @click.option("--json", "as_json", is_flag=True) @click.pass_context def insert_value_command(ctx, path, index, value, as_json=False): - """Inserts value at index of list behind config key.""" - path = _to_settings_path(path) + """Inserts value at index of list behind config key.""" + path = _to_settings_path(path) - if as_json: - try: - value = json.loads(value) - except Exception as e: - click.echo(e.message, err=True) - ctx.exit(-1) + if as_json: + try: + value = json.loads(value) + except Exception as e: + click.echo(str(e), err=True) + ctx.exit(-1) - current = ctx.obj.settings.get(path) - if current is None: - current = [] - if not isinstance(current, list): - click.echo("Cannot insert into non-list value at given path", err=True) - ctx.exit(-1) + current = ctx.obj.settings.get(path) + if current is None: + current = [] + if not isinstance(current, list): + click.echo("Cannot insert into non-list value at given path", err=True) + ctx.exit(-1) - current.insert(index, value) - _set_helper(ctx.obj.settings, path, current) + current.insert(index, value) + _set_helper(ctx.obj.settings, path, current) @config.command(name="remove_value") @@ -164,78 +174,80 @@ def insert_value_command(ctx, path, index, value, as_json=False): @click.option("--json", "as_json", is_flag=True) @click.pass_context def remove_value_command(ctx, path, value, as_json=False): - """Removes value from list at config path.""" - path = _to_settings_path(path) + """Removes value from list at config path.""" + path = _to_settings_path(path) - if as_json: - try: - value = json.loads(value) - except Exception as e: - click.echo(e.message, err=True) - ctx.exit(-1) + if as_json: + try: + value = json.loads(value) + except Exception as e: + click.echo(str(e), err=True) + ctx.exit(-1) - current = ctx.obj.settings.get(path) - if current is None: - current = [] - if not isinstance(current, list): - click.echo("Cannot remove value from non-list value at given path", err=True) - ctx.exit(-1) + current = ctx.obj.settings.get(path) + if current is None: + current = [] + if not isinstance(current, list): + click.echo("Cannot remove value from non-list value at given path", err=True) + ctx.exit(-1) - if not value in current: - click.echo("Value is not contained in list at given path") - ctx.exit() + if value not in current: + click.echo("Value is not contained in list at given path") + ctx.exit() - current.remove(value) - _set_helper(ctx.obj.settings, path, current) + current.remove(value) + _set_helper(ctx.obj.settings, path, current) @config.command(name="get") @click.argument("path", type=click.STRING) -@click.option("--json", "as_json", is_flag=True, - help="Output value formatted as JSON") -@click.option("--yaml", "as_yaml", is_flag=True, - help="Output value formatted as YAML") -@click.option("--raw", "as_raw", is_flag=True, - help="Output value as raw string representation") +@click.option("--json", "as_json", is_flag=True, help="Output value formatted as JSON") +@click.option("--yaml", "as_yaml", is_flag=True, help="Output value formatted as YAML") +@click.option( + "--raw", "as_raw", is_flag=True, help="Output value as raw string representation" +) @standard_options(hidden=True) @click.pass_context def get_command(ctx, path, as_json=False, as_yaml=False, as_raw=False): - """Retrieves value from config path.""" - path = _to_settings_path(path) - value = ctx.obj.settings.get(path, merged=True) + """Retrieves value from config path.""" + path = _to_settings_path(path) + value = ctx.obj.settings.get(path, merged=True) - if as_json: - output = json.dumps(value) - elif as_yaml: - output = yaml.safe_dump(value, default_flow_style=False, indent=" ", allow_unicode=True) - elif as_raw: - output = value - else: - output = pprint.pformat(value) + if as_json: + output = json.dumps(value) + elif as_yaml: + output = yaml.safe_dump( + value, default_flow_style=False, indent=2, allow_unicode=True + ) + elif as_raw: + output = value + else: + output = pprint.pformat(value) - click.echo(output) + click.echo(output) @config.command(name="effective") -@click.option("--json", "as_json", is_flag=True, - help="Output value formatted as JSON") -@click.option("--yaml", "as_yaml", is_flag=True, - help="Output value formatted as YAML") -@click.option("--raw", "as_raw", is_flag=True, - help="Output value as raw string representation") +@click.option("--json", "as_json", is_flag=True, help="Output value formatted as JSON") +@click.option("--yaml", "as_yaml", is_flag=True, help="Output value formatted as YAML") +@click.option( + "--raw", "as_raw", is_flag=True, help="Output value as raw string representation" +) @standard_options(hidden=True) @click.pass_context def effective_command(ctx, as_json=False, as_yaml=False, as_raw=False): - """Retrieves the full effective config.""" - value = ctx.obj.settings.effective - - if as_json: - output = json.dumps(value) - elif as_yaml: - output = yaml.safe_dump(value, default_flow_style=False, indent=" ", allow_unicode=True) - elif as_raw: - output = value - else: - output = pprint.pformat(value) - - click.echo(output) + """Retrieves the full effective config.""" + value = ctx.obj.settings.effective + + if as_json: + output = json.dumps(value) + elif as_yaml: + output = yaml.safe_dump( + value, default_flow_style=False, indent=2, allow_unicode=True + ) + elif as_raw: + output = value + else: + output = pprint.pformat(value) + + click.echo(output) diff --git a/src/octoprint/cli/dev.py b/src/octoprint/cli/dev.py index 0d8dd57a14..037cd734dc 100644 --- a/src/octoprint/cli/dev.py +++ b/src/octoprint/cli/dev.py @@ -1,237 +1,266 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals __author__ = "Gina Häußge " -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" import click -from past.builtins import basestring +click.disable_unicode_literals_warning = True + + class OctoPrintDevelCommands(click.MultiCommand): - """ - Custom `click.MultiCommand `_ - implementation that provides commands relevant for (plugin) development - based on availability of development dependencies. - """ - - sep = ":" - groups = ("plugin",) - - def __init__(self, *args, **kwargs): - click.MultiCommand.__init__(self, *args, **kwargs) - - from octoprint.util.commandline import CommandlineCaller - from functools import partial - - def log_util(f): - def log(*lines): - for line in lines: - f(line) - return log - - self.command_caller = CommandlineCaller() - self.command_caller.on_log_call = log_util(lambda x: click.echo(">> {}".format(x))) - self.command_caller.on_log_stdout = log_util(click.echo) - self.command_caller.on_log_stderr = log_util(partial(click.echo, err=True)) - - 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): - result = dict() - for group in self.groups: - for command in self._get_commands_from_prefix_methods("{}_".format(group)): - result[group + self.sep + command.name] = command - return result - - def list_commands(self, ctx): - result = [name for name in self._get_commands()] - result.sort() - return result - - def get_command(self, ctx, cmd_name): - commands = self._get_commands() - return commands.get(cmd_name, None) - - def plugin_new(self): - try: - import cookiecutter.main - except ImportError: - return None - - try: - # we depend on Cookiecutter >= 1.4 - from cookiecutter.prompt import StrictEnvironment - except ImportError: - return None - - import contextlib - - @contextlib.contextmanager - def custom_cookiecutter_config(config): - """ - Allows overriding cookiecutter's user config with a custom dict - with fallback to the original data. - """ - from octoprint.util import fallback_dict - - original_get_user_config = cookiecutter.main.get_user_config - try: - def f(*args, **kwargs): - original_config = original_get_user_config(*args, **kwargs) - return fallback_dict(config, original_config) - cookiecutter.main.get_user_config = f - yield - finally: - cookiecutter.main.get_user_config = original_get_user_config - - @contextlib.contextmanager - def custom_cookiecutter_prompt(options): - """ - Custom cookiecutter prompter for the template config. - - If a setting is available in the provided options (read from the CLI) - that will be used, otherwise the user will be prompted for a value - via click. - """ - original_prompt_for_config = cookiecutter.main.prompt_for_config - - def custom_prompt_for_config(context, no_input=False): - cookiecutter_dict = dict() - - env = StrictEnvironment() - - for key, raw in context['cookiecutter'].items(): - if key in options: - val = options[key] - else: - raw = raw if isinstance(raw, basestring) else str(raw) - val = env.from_string(raw).render(cookiecutter=cookiecutter_dict) - - if not no_input: - val = click.prompt(key, default=val) - - cookiecutter_dict[key] = val - return cookiecutter_dict - - try: - cookiecutter.main.prompt_for_config = custom_prompt_for_config - yield - finally: - cookiecutter.main.prompt_for_config = original_prompt_for_config - - @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") - @click.option("--email", "-e", help="The plugin author's mail address") - @click.option("--license", "-l", help="The plugin's license") - @click.option("--description", "-d", help="The plugin's description") - @click.option("--homepage", help="The plugin's homepage URL") - @click.option("--source", "-s", help="The URL to the plugin's source") - @click.option("--installurl", "-i", help="The plugin's install URL") - @click.argument("identifier", required=False) - def command(name, package, author, email, description, license, homepage, source, installurl, identifier): - """Creates a new plugin based on the OctoPrint Plugin cookiecutter template.""" - from octoprint.util import tempdir - - # deleting a git checkout folder might run into access errors due - # to write-protected sub folders, so we use a custom onerror handler - # that tries to fix such permissions - def onerror(func, path, exc_info): - """Originally from http://stackoverflow.com/a/2656405/2028598""" - import stat - import os - - if not os.access(path, os.W_OK): - os.chmod(path, stat.S_IWUSR) - func(path) - else: - raise - - with tempdir(onerror=onerror) as path: - custom = dict(cookiecutters_dir=path) - with custom_cookiecutter_config(custom): - raw_options = dict( - plugin_identifier=identifier, - plugin_package=package, - plugin_name=name, - full_name=author, - email=email, - plugin_description=description, - plugin_license=license, - plugin_homepage=homepage, - plugin_source=source, - plugin_installurl=installurl - ) - options = dict((k, v) for k, v in raw_options.items() if v is not None) - - with custom_cookiecutter_prompt(options): - cookiecutter.main.cookiecutter("gh:OctoPrint/cookiecutter-octoprint-plugin") - - 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, "-m", "pip", "install", "-e", "."], cwd=path) - - return command - - def plugin_uninstall(self): - @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 = [sys.executable, "-m", "pip", "uninstall", "--yes", name] - self.command_caller.call(call) - - return command + """ + Custom `click.MultiCommand `_ + implementation that provides commands relevant for (plugin) development + based on availability of development dependencies. + """ + + sep = ":" + groups = ("plugin",) + + def __init__(self, *args, **kwargs): + click.MultiCommand.__init__(self, *args, **kwargs) + + from functools import partial + + from octoprint.util.commandline import CommandlineCaller + + def log_util(f): + def log(*lines): + for line in lines: + f(line) + + return log + + self.command_caller = CommandlineCaller() + self.command_caller.on_log_call = log_util( + lambda x: click.echo(">> {}".format(x)) + ) + self.command_caller.on_log_stdout = log_util(click.echo) + self.command_caller.on_log_stderr = log_util(partial(click.echo, err=True)) + + 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): + result = {} + for group in self.groups: + for command in self._get_commands_from_prefix_methods("{}_".format(group)): + result[group + self.sep + command.name] = command + return result + + def list_commands(self, ctx): + result = [name for name in self._get_commands()] + result.sort() + return result + + def get_command(self, ctx, cmd_name): + commands = self._get_commands() + return commands.get(cmd_name, None) + + def plugin_new(self): + try: + import cookiecutter.main + except ImportError: + return None + + try: + # we depend on Cookiecutter >= 1.4 + from cookiecutter.prompt import StrictEnvironment + except ImportError: + return None + + import contextlib + + @contextlib.contextmanager + def custom_cookiecutter_config(config): + """ + Allows overriding cookiecutter's user config with a custom dict + with fallback to the original data. + """ + from octoprint.util import fallback_dict + + original_get_user_config = cookiecutter.main.get_user_config + try: + + def f(*args, **kwargs): + original_config = original_get_user_config(*args, **kwargs) + return fallback_dict(config, original_config) + + cookiecutter.main.get_user_config = f + yield + finally: + cookiecutter.main.get_user_config = original_get_user_config + + @contextlib.contextmanager + def custom_cookiecutter_prompt(options): + """ + Custom cookiecutter prompter for the template config. + + If a setting is available in the provided options (read from the CLI) + that will be used, otherwise the user will be prompted for a value + via click. + """ + original_prompt_for_config = cookiecutter.main.prompt_for_config + + def custom_prompt_for_config(context, no_input=False): + cookiecutter_dict = {} + + env = StrictEnvironment() + + for key, raw in context["cookiecutter"].items(): + if key in options: + val = options[key] + else: + if not isinstance(raw, str): + raw = str(raw) + val = env.from_string(raw).render(cookiecutter=cookiecutter_dict) + + if not no_input: + val = click.prompt(key, default=val) + + cookiecutter_dict[key] = val + return cookiecutter_dict + + try: + cookiecutter.main.prompt_for_config = custom_prompt_for_config + yield + finally: + cookiecutter.main.prompt_for_config = original_prompt_for_config + + @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") + @click.option("--email", "-e", help="The plugin author's mail address") + @click.option("--license", "-l", help="The plugin's license") + @click.option("--description", "-d", help="The plugin's description") + @click.option("--homepage", help="The plugin's homepage URL") + @click.option("--source", "-s", help="The URL to the plugin's source") + @click.option("--installurl", "-i", help="The plugin's install URL") + @click.argument("identifier", required=False) + def command( + name, + package, + author, + email, + description, + license, + homepage, + source, + installurl, + identifier, + ): + """Creates a new plugin based on the OctoPrint Plugin cookiecutter template.""" + from octoprint.util import tempdir + + # deleting a git checkout folder might run into access errors due + # to write-protected sub folders, so we use a custom onerror handler + # that tries to fix such permissions + def onerror(func, path, exc_info): + """Originally from http://stackoverflow.com/a/2656405/2028598""" + import os + import stat + + if not os.access(path, os.W_OK): + os.chmod(path, stat.S_IWUSR) + func(path) + else: + raise + + with tempdir(onerror=onerror) as path: + custom = {"cookiecutters_dir": path} + with custom_cookiecutter_config(custom): + raw_options = { + "plugin_identifier": identifier, + "plugin_package": package, + "plugin_name": name, + "full_name": author, + "email": email, + "plugin_description": description, + "plugin_license": license, + "plugin_homepage": homepage, + "plugin_source": source, + "plugin_installurl": installurl, + } + options = {k: v for k, v in raw_options.items() if v is not None} + + with custom_cookiecutter_prompt(options): + cookiecutter.main.cookiecutter( + "gh:OctoPrint/cookiecutter-octoprint-plugin" + ) + + 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, "-m", "pip", "install", "-e", "."], cwd=path + ) + + return command + + def plugin_uninstall(self): + @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 = [sys.executable, "-m", "pip", "uninstall", "--yes", name] + self.command_caller.call(call) + + return command + @click.group() def dev_commands(): - pass + pass + @dev_commands.group(name="dev", cls=OctoPrintDevelCommands) def dev(): - """Additional commands for development tasks.""" - pass + """Additional commands for development tasks.""" + pass diff --git a/src/octoprint/cli/plugins.py b/src/octoprint/cli/plugins.py index facfc80cbc..1ca734ec96 100644 --- a/src/octoprint/cli/plugins.py +++ b/src/octoprint/cli/plugins.py @@ -1,108 +1,152 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" -import click import logging +import logging.config -from octoprint.cli import pass_octoprint_ctx, OctoPrintContext, get_ctx_obj_option - -#~~ "octoprint plugin:command" commands - -class OctoPrintPluginCommands(click.MultiCommand): - """ - Custom `click.MultiCommand `_ - implementation that collects commands from the plugin hook - :ref:`octoprint.cli.commands `. - - .. attribute:: settings - - The global :class:`~octoprint.settings.Settings` instance. - - .. attribute:: plugin_manager - - The :class:`~octoprint.plugin.core.PluginManager` instance. - """ - - sep = ":" - - def __init__(self, *args, **kwargs): - click.MultiCommand.__init__(self, *args, **kwargs) - - self.settings = None - self.plugin_manager = None - self.hooks = dict() - - self._logger = logging.getLogger(__name__) - self._initialized = False - - def _initialize(self, ctx): - if self._initialized: - return - - click.echo("Initializing settings & plugin subsystem...") - if ctx.obj is None: - ctx.obj = OctoPrintContext() - - # initialize settings and plugin manager based on provided - # context (basedir and configfile) - from octoprint import init_settings, init_pluginsystem, FatalStartupError - try: - self.settings = init_settings(get_ctx_obj_option(ctx, "basedir", None), get_ctx_obj_option(ctx, "configfile", None)) - self.plugin_manager = init_pluginsystem(self.settings, - safe_mode=get_ctx_obj_option(ctx, "safe_mode", False)) - except FatalStartupError as e: - click.echo(e.message, err=True) - click.echo("There was a fatal error initializing the settings or the plugin system.", err=True) - ctx.exit(-1) - - # fetch registered hooks - self.hooks = self.plugin_manager.get_hooks("octoprint.cli.commands") - - logging.basicConfig(level=logging.DEBUG if ctx.obj.verbosity > 0 else logging.WARN) - - self._initialized = True +import click - def list_commands(self, ctx): - self._initialize(ctx) - result = [name for name in self._get_commands()] - result.sort() - return result +from octoprint.cli import OctoPrintContext, get_ctx_obj_option, pass_octoprint_ctx +from octoprint.util import dict_merge - def get_command(self, ctx, cmd_name): - self._initialize(ctx) - commands = self._get_commands() - return commands.get(cmd_name, None) +click.disable_unicode_literals_warning = True +LOGGING_CONFIG = { + "version": 1, + "formatters": {"brief": {"format": "%(message)s"}}, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "brief", + "stream": "ext://sys.stdout", + } + }, + "loggers": {"octoprint.plugin.core": {"level": logging.ERROR}}, + "root": {"level": logging.WARNING}, +} - def _get_commands(self): - """Fetch all commands from plugins providing any.""" +# ~~ "octoprint plugin:command" commands - import collections - result = collections.OrderedDict() - for name, hook in self.hooks.items(): - try: - commands = hook(self, pass_octoprint_ctx) - for command in commands: - if not isinstance(command, click.Command): - self._logger.warn("Plugin {} provided invalid CLI command, ignoring it: {!r}".format(name, command)) - continue - result[name + self.sep + command.name] = command - except: - self._logger.exception("Error while retrieving cli commands for plugin {}".format(name)) +class OctoPrintPluginCommands(click.MultiCommand): + """ + Custom `click.MultiCommand `_ + implementation that collects commands from the plugin hook + :ref:`octoprint.cli.commands `. + + .. attribute:: settings + + The global :class:`~octoprint.settings.Settings` instance. + + .. attribute:: plugin_manager + + The :class:`~octoprint.plugin.core.PluginManager` instance. + """ + + sep = ":" + + def __init__(self, *args, **kwargs): + click.MultiCommand.__init__(self, *args, **kwargs) + + self.settings = None + self.plugin_manager = None + self.hooks = {} + + self._logger = logging.getLogger(__name__) + self._initialized = False + + def _initialize(self, ctx): + if self._initialized: + return + + click.echo("Initializing settings & plugin subsystem...") + if ctx.obj is None: + ctx.obj = OctoPrintContext() + + logging_config = dict_merge( + LOGGING_CONFIG, + { + "root": { + "level": logging.DEBUG if ctx.obj.verbosity > 0 else logging.WARNING + } + }, + ) + logging.config.dictConfig(logging_config) + + # initialize settings and plugin manager based on provided + # context (basedir and configfile) + from octoprint import FatalStartupError, init_pluginsystem, init_settings + + try: + self.settings = init_settings( + get_ctx_obj_option(ctx, "basedir", None), + get_ctx_obj_option(ctx, "configfile", None), + overlays=get_ctx_obj_option(ctx, "overlays", None), + ) + self.plugin_manager = init_pluginsystem( + self.settings, safe_mode=get_ctx_obj_option(ctx, "safe_mode", False) + ) + except FatalStartupError as e: + click.echo(str(e), err=True) + click.echo( + "There was a fatal error initializing the settings or the plugin system.", + err=True, + ) + ctx.exit(-1) + + # fetch registered hooks + self.hooks = self.plugin_manager.get_hooks("octoprint.cli.commands") + + self._initialized = True + + def list_commands(self, ctx): + self._initialize(ctx) + result = [name for name in self._get_commands()] + result.sort() + return result + + def get_command(self, ctx, cmd_name): + self._initialize(ctx) + commands = self._get_commands() + return commands.get(cmd_name, None) + + def _get_commands(self): + """Fetch all commands from plugins providing any.""" + + import collections + + result = collections.OrderedDict() + + for name, hook in self.hooks.items(): + try: + commands = hook(self, pass_octoprint_ctx) + for command in commands: + if not isinstance(command, click.Command): + self._logger.warning( + "Plugin {} provided invalid CLI command, ignoring it: {!r}".format( + name, command + ) + ) + continue + result[name + self.sep + command.name] = command + except Exception: + self._logger.exception( + "Error while retrieving cli commands for plugin {}".format(name), + extra={"plugin": name}, + ) + + return result - return result @click.group() @pass_octoprint_ctx def plugin_commands(obj): - pass + pass + @plugin_commands.group(name="plugins", cls=OctoPrintPluginCommands) def plugins(): - """Additional commands provided by plugins.""" - pass - + """Additional commands provided by plugins.""" + pass diff --git a/src/octoprint/cli/server.py b/src/octoprint/cli/server.py index 1e7a98f191..e5e61f92a7 100644 --- a/src/octoprint/cli/server.py +++ b/src/octoprint/cli/server.py @@ -1,159 +1,288 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" -import click import logging import sys -from octoprint.cli import bulk_options, standard_options, set_ctx_obj_option, get_ctx_obj_option - -def run_server(basedir, configfile, host, port, debug, allow_root, logging_config, verbosity, safe_mode, - ignore_blacklist, octoprint_daemon=None): - """Initializes the environment and starts up the server.""" - - from octoprint import init_platform, __display_version__, FatalStartupError - - def log_startup(recorder=None, safe_mode=None, **kwargs): - from octoprint.logging import get_divider_line - - logger = logging.getLogger("octoprint.startup") - - logger.info(get_divider_line("*")) - logger.info("Starting OctoPrint {}".format(__display_version__)) - if safe_mode: - logger.info("Starting in SAFE MODE. Third party plugins will be disabled!") - - if recorder and len(recorder): - logger.info(get_divider_line("-", "Logged during platform initialization:")) - - from octoprint.logging.handlers import CombinedLogHandler - handler = CombinedLogHandler(*logging.getLogger().handlers) - recorder.setTarget(handler) - recorder.flush() - - logger.info(get_divider_line("-")) - - from octoprint import urllib3_ssl - if not urllib3_ssl: - logging.getLogger("octoprint.server")\ - .warn("requests/urllib3 will run in an insecure SSL environment. " - "You might see corresponding warnings logged later " - "(\"InsecurePlatformWarning\"). It is recommended to either " - "update to a Python version >= 2.7.9 or alternatively " - "install PyOpenSSL plus its dependencies. For details see " - "https://urllib3.readthedocs.org/en/latest/security.html#openssl-pyopenssl") - logger.info(get_divider_line("*")) - - def log_register_rollover(safe_mode=None, plugin_manager=None, environment_detector=None, **kwargs): - from octoprint.logging import get_handler, log_to_handler, get_divider_line - from octoprint.logging.handlers import OctoPrintLogHandler - - def rollover_callback(): - handler = get_handler("file") - if handler is None: - return - - logger = logging.getLogger("octoprint.server") - - def _log(message, level=logging.INFO): - log_to_handler(logger, handler, level, message) - - _log(get_divider_line("-", "Log roll over detected")) - _log("OctoPrint {}".format(__display_version__)) - if safe_mode: - _log("SAFE MODE is active. Third party plugins are disabled!") - plugin_manager.log_all_plugins(only_to_handler=handler) - environment_detector.log_detected_environment(only_to_handler=handler) - _log(get_divider_line("-")) - - OctoPrintLogHandler.registerRolloverCallback(rollover_callback) - - try: - components = init_platform(basedir, configfile, - logging_file=logging_config, - debug=debug, - verbosity=verbosity, - uncaught_logger=__name__, - safe_mode=safe_mode, - ignore_blacklist=ignore_blacklist, - after_safe_mode=log_startup, - after_environment_detector=log_register_rollover) - - settings, _, safe_mode, event_manager, connectivity_checker, plugin_manager, environment_detector = components - - except FatalStartupError as e: - click.echo(e.message, err=True) - click.echo("There was a fatal error starting up OctoPrint.", err=True) - else: - from octoprint.server import Server - octoprint_server = Server(settings=settings, - plugin_manager=plugin_manager, - event_manager=event_manager, - connectivity_checker=connectivity_checker, - environment_detector=environment_detector, - host=host, - port=port, - debug=debug, - safe_mode=safe_mode, - allow_root=allow_root, - octoprint_daemon=octoprint_daemon) - octoprint_server.run() - -#~~ server options - -server_options = bulk_options([ - click.option("--host", type=click.STRING, callback=set_ctx_obj_option, - help="Specify the host on which to bind the server."), - click.option("--port", type=click.INT, callback=set_ctx_obj_option, - help="Specify the port on which to bind the server."), - click.option("--logging", type=click.Path(), callback=set_ctx_obj_option, - help="Specify the config file to use for configuring logging."), - click.option("--iknowwhatimdoing", "allow_root", is_flag=True, callback=set_ctx_obj_option, - help="Allow OctoPrint to run as user root."), - click.option("--debug", is_flag=True, callback=set_ctx_obj_option, - help="Enable debug mode."), - click.option("--ignore-blacklist", "ignore_blacklist", is_flag=True, callback=set_ctx_obj_option, - help="Disable processing of the plugin blacklist.") -]) -"""Decorator to add the options shared among the server commands: ``--host``, ``--port``, +import click + +from octoprint.cli import ( + bulk_options, + get_ctx_obj_option, + set_ctx_obj_option, + standard_options, +) + +click.disable_unicode_literals_warning = True + + +def run_server( + basedir, + configfile, + host, + port, + v6_only, + debug, + allow_root, + logging_config, + verbosity, + safe_mode, + ignore_blacklist, + octoprint_daemon=None, + overlays=None, +): + """Initializes the environment and starts up the server.""" + + from octoprint import FatalStartupError, __display_version__, init_platform + + def log_startup(recorder=None, safe_mode=None, **kwargs): + from octoprint.logging import get_divider_line + from octoprint.logging.handlers import PluginTimingsLogHandler + + logger = logging.getLogger("octoprint.startup") + PluginTimingsLogHandler.arm_rollover() + + logger.info(get_divider_line("*")) + logger.info("Starting OctoPrint {}".format(__display_version__)) + if safe_mode: + logger.info("Starting in SAFE MODE. Third party plugins will be disabled!") + if safe_mode == "flag": + reason = "command line flag" + elif safe_mode == "settings": + reason = "setting in config.yaml" + elif safe_mode == "incomplete_startup": + reason = "problem during last startup" + else: + reason = "unknown" + logger.info("Reason for safe mode: {}".format(reason)) + + if recorder and len(recorder): + logger.info(get_divider_line("-", "Logged during platform initialization:")) + + from octoprint.logging.handlers import CombinedLogHandler + + handler = CombinedLogHandler(*logging.getLogger().handlers) + recorder.setTarget(handler) + recorder.flush() + + logger.info(get_divider_line("-")) + + from octoprint import urllib3_ssl + + if not urllib3_ssl: + logging.getLogger("octoprint.server").warning( + "requests/urllib3 will run in an insecure SSL environment. " + "You might see corresponding warnings logged later " + '("InsecurePlatformWarning"). It is recommended to either ' + "update to a Python version >= 2.7.9 or alternatively " + "install PyOpenSSL plus its dependencies. For details see " + "https://urllib3.readthedocs.org/en/latest/security.html#openssl-pyopenssl" + ) + logger.info(get_divider_line("*")) + + def log_register_rollover( + safe_mode=None, plugin_manager=None, environment_detector=None, **kwargs + ): + from octoprint.logging import get_divider_line, get_handler, log_to_handler + from octoprint.logging.handlers import OctoPrintLogHandler + + def rollover_callback(): + handler = get_handler("file") + if handler is None: + return + + logger = logging.getLogger("octoprint.server") + + def _log(message, level=logging.INFO): + log_to_handler(logger, handler, level, message) + + _log(get_divider_line("-", "Log roll over detected")) + _log("OctoPrint {}".format(__display_version__)) + if safe_mode: + _log("SAFE MODE is active. Third party plugins are disabled!") + plugin_manager.log_all_plugins(only_to_handler=handler) + environment_detector.log_detected_environment(only_to_handler=handler) + _log(get_divider_line("-")) + + OctoPrintLogHandler.registerRolloverCallback(rollover_callback) + + try: + components = init_platform( + basedir, + configfile, + overlays=overlays, + logging_file=logging_config, + debug=debug, + verbosity=verbosity, + uncaught_logger=__name__, + safe_mode=safe_mode, + ignore_blacklist=ignore_blacklist, + after_safe_mode=log_startup, + after_environment_detector=log_register_rollover, + ) + ( + settings, + _, + safe_mode, + event_manager, + connectivity_checker, + plugin_manager, + environment_detector, + ) = components + + except FatalStartupError as e: + logger = logging.getLogger("octoprint.startup").fatal + echo = lambda x: click.echo(x, err=True) + + for method in logger, echo: + method(str(e)) + method("There was a fatal error starting up OctoPrint.") + + else: + from octoprint.server import CannotStartServerException, Server + + octoprint_server = Server( + settings=settings, + plugin_manager=plugin_manager, + event_manager=event_manager, + connectivity_checker=connectivity_checker, + environment_detector=environment_detector, + host=host, + port=port, + v6_only=v6_only, + debug=debug, + safe_mode=safe_mode, + allow_root=allow_root, + octoprint_daemon=octoprint_daemon, + ) + + try: + octoprint_server.run() + except CannotStartServerException as e: + logger = logging.getLogger("octoprint.startup").fatal + echo = lambda x: click.echo(x, err=True) + + for method in logger, echo: + method(str(e)) + method("There was a fatal error starting up OctoPrint.") + + +# ~~ server options + +server_options = bulk_options( + [ + click.option( + "--host", + type=click.STRING, + callback=set_ctx_obj_option, + help="Specify the host address on which to bind the server.", + ), + click.option( + "--port", + type=click.INT, + callback=set_ctx_obj_option, + help="Specify the port on which to bind the server.", + ), + click.option( + "-4", + "--ipv4", + "v4", + is_flag=True, + callback=set_ctx_obj_option, + help="Bind to IPv4 addresses only. Implies '--host 0.0.0.0'. Silently ignored if -6 is present.", + ), + click.option( + "-6", + "--ipv6", + "v6", + is_flag=True, + callback=set_ctx_obj_option, + help="Bind to IPv6 addresses only. Disables dual stack when binding to any v6 addresses. Silently ignored if -4 is present.", + ), + click.option( + "--logging", + type=click.Path(), + callback=set_ctx_obj_option, + help="Specify the config file to use for configuring logging.", + ), + click.option( + "--iknowwhatimdoing", + "allow_root", + is_flag=True, + callback=set_ctx_obj_option, + help="Allow OctoPrint to run as user root.", + ), + click.option( + "--debug", + is_flag=True, + callback=set_ctx_obj_option, + help="Enable debug mode.", + ), + click.option( + "--ignore-blacklist", + "ignore_blacklist", + is_flag=True, + callback=set_ctx_obj_option, + help="Disable processing of the plugin blacklist.", + ), + ] +) +"""Decorator to add the options shared among the server commands: ``--host``, ``--port``, ``-4``, ``-6`` ``--logging``, ``--iknowwhatimdoing`` and ``--debug``.""" -daemon_options = bulk_options([ - click.option("--pid", type=click.Path(), default="/tmp/octoprint.pid", callback=set_ctx_obj_option, - help="Pidfile to use for daemonizing.") -]) +daemon_options = bulk_options( + [ + click.option( + "--pid", + type=click.Path(), + default="/tmp/octoprint.pid", + callback=set_ctx_obj_option, + help="Pidfile to use for daemonizing.", + ) + ] +) """Decorator to add the options for the daemon subcommand: ``--pid``.""" -#~~ "octoprint serve" and "octoprint daemon" commands +# ~~ "octoprint serve" and "octoprint daemon" commands + @click.group() def server_commands(): - pass + pass @server_commands.command(name="safemode") @standard_options() @click.pass_context def enable_safemode(ctx, **kwargs): - """Sets the safe mode flag for the next start.""" - from octoprint import init_settings, FatalStartupError - - logging.basicConfig(level=logging.DEBUG if get_ctx_obj_option(ctx, "verbosity", 0) > 0 else logging.WARN) - try: - settings = init_settings(get_ctx_obj_option(ctx, "basedir", None), get_ctx_obj_option(ctx, "configfile", None)) - except FatalStartupError as e: - click.echo(e.message, err=True) - click.echo("There was a fatal error initializing the settings manager.", err=True) - ctx.exit(-1) - - settings.setBoolean(["server", "startOnceInSafeMode"], True) - settings.save() - - click.echo("Safe mode flag set, OctoPrint will start in safe mode on next restart.") + """Sets the safe mode flag for the next start.""" + from octoprint import FatalStartupError, init_settings + + logging.basicConfig( + level=logging.DEBUG + if get_ctx_obj_option(ctx, "verbosity", 0) > 0 + else logging.WARN + ) + try: + settings = init_settings( + get_ctx_obj_option(ctx, "basedir", None), + get_ctx_obj_option(ctx, "configfile", None), + overlays=get_ctx_obj_option(ctx, "overlays", None), + ) + except FatalStartupError as e: + click.echo(str(e), err=True) + click.echo("There was a fatal error initializing the settings manager.", err=True) + ctx.exit(-1) + else: + settings.setBoolean(["server", "startOnceInSafeMode"], True) + settings.save() + + click.echo( + "Safe mode flag set, OctoPrint will start in safe mode on next restart." + ) @server_commands.command(name="serve") @@ -161,98 +290,163 @@ def enable_safemode(ctx, **kwargs): @server_options @click.pass_context def serve_command(ctx, **kwargs): - """Starts the OctoPrint server.""" - - def get_value(key): - return get_ctx_obj_option(ctx, key, kwargs.get(key)) - - host = get_value("host") - port = get_value("port") - logging = get_value("logging") - allow_root = get_value("allow_root") - debug = get_value("debug") - - basedir = get_value("basedir") - configfile = get_value("configfile") - verbosity = get_value("verbosity") - safe_mode = get_value("safe_mode") - ignore_blacklist = get_value("ignore_blacklist") - - run_server(basedir, configfile, host, port, debug, - allow_root, logging, verbosity, safe_mode, - ignore_blacklist) + """Starts the OctoPrint server.""" + + def get_value(key): + return get_ctx_obj_option(ctx, key, kwargs.get(key)) + + host = get_value("host") + port = get_value("port") + v4 = get_value("v4") + v6 = get_value("v6") + logging = get_value("logging") + allow_root = get_value("allow_root") + debug = get_value("debug") + + basedir = get_value("basedir") + configfile = get_value("configfile") + verbosity = get_value("verbosity") + safe_mode = "flag" if get_value("safe_mode") else None + ignore_blacklist = get_value("ignore_blacklist") + overlays = get_value("overlays") + + if v4 and not host: + host = "0.0.0.0" + + run_server( + basedir, + configfile, + host, + port, + v6, + debug, + allow_root, + logging, + verbosity, + safe_mode, + ignore_blacklist, + overlays=overlays, + ) if sys.platform != "win32" and sys.platform != "darwin": - # we do not support daemon mode under windows or macosx - - @server_commands.command(name="daemon") - @standard_options() - @server_options - @daemon_options - @click.argument("command", type=click.Choice(["start", "stop", "restart", "status"]), - metavar="start|stop|restart|status") - @click.pass_context - def daemon_command(ctx, command, **kwargs): - """ - Starts, stops or restarts in daemon mode. - - Please note that daemon mode is not supported under Windows and MacOSX right now. - """ - - def get_value(key): - return get_ctx_obj_option(ctx, key, kwargs.get(key)) - - host = get_value("host") - port = get_value("port") - logging = get_value("logging") - allow_root = get_value("allow_root") - debug = get_value("debug") - pid = get_value("pid") - - basedir = get_value("basedir") - configfile = get_value("configfile") - verbosity = get_value("verbosity") - safe_mode = get_value("safe_mode") - ignore_blacklist = get_value("ignore_blacklist") - - if pid is None: - click.echo("No path to a pidfile set", - file=sys.stderr) - sys.exit(1) - - from octoprint.daemon import Daemon - class OctoPrintDaemon(Daemon): - def __init__(self, pidfile, basedir, configfile, host, port, debug, allow_root, logging_config, verbosity, - safe_mode, ignore_blacklist): - Daemon.__init__(self, pidfile) - - self._basedir = basedir - self._configfile = configfile - self._host = host - self._port = port - self._debug = debug - self._allow_root = allow_root - self._logging_config = logging_config - self._verbosity = verbosity - self._safe_mode = safe_mode - self._ignore_blacklist = ignore_blacklist - - def run(self): - run_server(self._basedir, self._configfile, self._host, self._port, self._debug, - self._allow_root, self._logging_config, self._verbosity, self._safe_mode, - self._ignore_blacklist, octoprint_daemon=self) - - octoprint_daemon = OctoPrintDaemon(pid, basedir, configfile, host, port, debug, allow_root, logging, verbosity, - safe_mode, ignore_blacklist) - - if command == "start": - octoprint_daemon.start() - elif command == "stop": - octoprint_daemon.stop() - elif command == "restart": - octoprint_daemon.restart() - elif command == "status": - octoprint_daemon.status() - - + # we do not support daemon mode under windows or macosx + + @server_commands.command(name="daemon") + @standard_options() + @server_options + @daemon_options + @click.argument( + "command", + type=click.Choice(["start", "stop", "restart", "status"]), + metavar="start|stop|restart|status", + ) + @click.pass_context + def daemon_command(ctx, command, **kwargs): + """ + Starts, stops or restarts in daemon mode. + + Please note that daemon mode is not supported under Windows and MacOSX right now. + """ + + def get_value(key): + return get_ctx_obj_option(ctx, key, kwargs.get(key)) + + host = get_value("host") + port = get_value("port") + v4 = get_value("v4") + v6 = get_value("v6") + logging = get_value("logging") + allow_root = get_value("allow_root") + debug = get_value("debug") + pid = get_value("pid") + + basedir = get_value("basedir") + configfile = get_value("configfile") + overlays = get_value("overlays") + verbosity = get_value("verbosity") + safe_mode = "flag" if get_value("safe_mode") else None + ignore_blacklist = get_value("ignore_blacklist") + + if v4 and not host: + host = "0.0.0.0" + + if pid is None: + click.echo("No path to a pidfile set", file=sys.stderr) + sys.exit(1) + + from octoprint.daemon import Daemon + + class OctoPrintDaemon(Daemon): + def __init__( + self, + pidfile, + basedir, + configfile, + overlays, + host, + port, + v6_only, + debug, + allow_root, + logging_config, + verbosity, + safe_mode, + ignore_blacklist, + ): + Daemon.__init__(self, pidfile) + + self._basedir = basedir + self._configfile = configfile + self._overlays = overlays + self._host = host + self._port = port + self._v6_only = v6_only + self._debug = debug + self._allow_root = allow_root + self._logging_config = logging_config + self._verbosity = verbosity + self._safe_mode = safe_mode + self._ignore_blacklist = ignore_blacklist + + def run(self): + run_server( + self._basedir, + self._configfile, + self._host, + self._port, + self._v6_only, + self._debug, + self._allow_root, + self._logging_config, + self._verbosity, + self._safe_mode, + self._ignore_blacklist, + octoprint_daemon=self, + overlays=self._overlays, + ) + + octoprint_daemon = OctoPrintDaemon( + pid, + basedir, + configfile, + overlays, + host, + port, + v6, + debug, + allow_root, + logging, + verbosity, + safe_mode, + ignore_blacklist, + ) + + if command == "start": + octoprint_daemon.start() + elif command == "stop": + octoprint_daemon.stop() + elif command == "restart": + octoprint_daemon.restart() + elif command == "status": + octoprint_daemon.status() diff --git a/src/octoprint/cli/systeminfo.py b/src/octoprint/cli/systeminfo.py new file mode 100644 index 0000000000..5ceaf0c0fa --- /dev/null +++ b/src/octoprint/cli/systeminfo.py @@ -0,0 +1,191 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +import logging +import os + +import click +import zipstream + +from octoprint.cli import init_platform_for_cli, standard_options + +click.disable_unicode_literals_warning = True + + +def get_systeminfo(environment_detector, connectivity_checker, additional_fields=None): + from octoprint import __version__ + from octoprint.util import dict_flatten + + if additional_fields is None: + additional_fields = {} + + environment_detector.run_detection(notify_plugins=False) + + systeminfo = { + "octoprint": {"version": __version__}, + "connectivity": connectivity_checker.as_dict(), + "env": environment_detector.environment, + } + + # flatten and filter + flattened = dict_flatten(systeminfo) + flattened["env.python.virtualenv"] = "env.python.virtualenv" in flattened + + for k, v in additional_fields.items(): + if k not in flattened: + flattened[k] = v + + return flattened + + +def get_systeminfo_bundle(systeminfo, logbase, printer=None, plugin_manager=None): + from octoprint.util import to_bytes + + systeminfotxt = [] + for k in sorted(systeminfo.keys()): + systeminfotxt.append("{}: {}".format(k, systeminfo[k])) + + terminaltxt = None + if printer and printer.is_operational(): + firmware_info = printer.firmware_info + if firmware_info: + systeminfo["printer.firmware"] = firmware_info["name"] + + if hasattr(printer, "_log"): + terminaltxt = list(printer._log) + + try: + import zlib # noqa: F401 + + compress_type = zipstream.ZIP_DEFLATED + except ImportError: + # no zlib, no compression + compress_type = zipstream.ZIP_STORED + + z = zipstream.ZipFile() + + # add systeminfo + z.writestr( + "systeminfo.txt", to_bytes("\n".join(systeminfotxt)), compress_type=compress_type + ) + + # add terminal.txt, if available + if terminaltxt: + z.writestr( + "terminal.txt", to_bytes("\n".join(terminaltxt)), compress_type=compress_type + ) + + # add logs + for log in ( + "octoprint.log", + "serial.log", + ): + logpath = os.path.join(logbase, log) + if os.path.exists(logpath): + z.write(logpath, arcname=log, compress_type=compress_type) + + # add additional bundle contents from bundled plugins + if plugin_manager: + for name, hook in plugin_manager.get_hooks( + "octoprint.systeminfo.additional_bundle_files" + ).items(): + try: + plugin = plugin_manager.get_plugin_info(name) + if not plugin.bundled: + # we only support this for bundled plugins because we don't want + # third party logs to blow up the bundles + continue + + logs = hook() + + for log, content in logs.items(): + if isinstance(content, str): + # log path + if os.path.exists(content) and os.access(content, os.R_OK): + z.write(content, arcname=log, compress_type=compress_type) + elif callable(content): + # content generating callable + z.writestr(log, to_bytes(content()), compress_type=compress_type) + except Exception: + logging.getLogger(__name__).exception( + "Error while retrieving additional bundle contents for plugin {}".format( + name + ), + extra={"plugin": name}, + ) + + return z + + +def get_systeminfo_bundle_name(): + import time + + return "octoprint-systeminfo-{}.zip".format(time.strftime("%Y%m%d%H%M%S")) + + +@click.group() +def systeminfo_commands(): + pass + + +@systeminfo_commands.command(name="systeminfo") +@standard_options() +@click.argument( + "path", + nargs=1, + required=False, + type=click.Path(writable=True, dir_okay=True, resolve_path=True), +) +@click.pass_context +def systeminfo_command(ctx, path, **kwargs): + """Retrieves and prints the system info.""" + logging.disable(logging.ERROR) + try: + ( + settings, + logger, + safe_mode, + event_manager, + connectivity_checker, + plugin_manager, + environment_detector, + ) = init_platform_for_cli(ctx) + except Exception as e: + click.echo(str(e), err=True) + click.echo("There was a fatal error initializing the platform.", err=True) + ctx.exit(-1) + else: + systeminfo = get_systeminfo( + environment_detector, connectivity_checker, {"systeminfo.generator": "cli"} + ) + + if path: + # create zip at path + zipfilename = os.path.join(path, get_systeminfo_bundle_name()) + click.echo("Writing systeminfo bundle to {}...".format(zipfilename)) + + z = get_systeminfo_bundle( + systeminfo, settings.getBaseFolder("logs"), plugin_manager=plugin_manager + ) + try: + with open(zipfilename, "wb") as f: + for data in z: + f.write(data) + except Exception as e: + click.echo(str(e), err=True) + click.echo( + "There was an error writing to {}.".format(zipfilename), err=True + ) + ctx.exit(-1) + + click.echo("Done!") + click.echo(zipfilename) + + else: + # output systeminfo to console + for k in sorted(systeminfo.keys()): + click.echo("{}: {}".format(k, systeminfo[k])) + ctx.exit(0) diff --git a/src/octoprint/cli/user.py b/src/octoprint/cli/user.py new file mode 100644 index 0000000000..5c15293dd3 --- /dev/null +++ b/src/octoprint/cli/user.py @@ -0,0 +1,223 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2019 The OctoPrint Project - Released under terms of the AGPLv3 License" + + +import logging + +import click + +from octoprint import init_settings +from octoprint.access.groups import FilebasedGroupManager +from octoprint.access.users import ( + CorruptUserStorage, + FilebasedUserManager, + UnknownUser, + UserAlreadyExists, +) +from octoprint.cli import get_ctx_obj_option +from octoprint.util import get_class, sv + +click.disable_unicode_literals_warning = True + +# ~~ "octoprint user" commands + + +@click.group() +def user_commands(): + pass + + +@user_commands.group(name="user") +@click.pass_context +def user(ctx): + """ + User management. + + Note that this currently only supports managing user accounts stored in the configured user manager, not any + user managers added through plugins and the "octoprint.users.factory" hook. + """ + try: + logging.basicConfig( + level=logging.DEBUG + if get_ctx_obj_option(ctx, "verbosity", 0) > 0 + else logging.WARN + ) + settings = init_settings( + get_ctx_obj_option(ctx, "basedir", None), + get_ctx_obj_option(ctx, "configfile", None), + overlays=get_ctx_obj_option(ctx, "overlays", None), + ) + + group_manager_name = settings.get(["accessControl", "groupManager"]) + try: + clazz = get_class(group_manager_name) + group_manager = clazz() + except AttributeError: + click.echo( + "Could not instantiate group manager {}, " + "falling back to FilebasedGroupManager!".format(group_manager_name), + err=True, + ) + group_manager = FilebasedGroupManager() + + ctx.obj.group_manager = group_manager + + name = settings.get(["accessControl", "userManager"]) + try: + clazz = get_class(name) + user_manager = clazz(group_manager=group_manager, settings=settings) + except CorruptUserStorage: + raise + except Exception: + click.echo( + "Could not instantiate user manager {}, falling back to FilebasedUserManager!".format( + name + ), + err=True, + ) + user_manager = FilebasedUserManager(group_manager, settings=settings) + + ctx.obj.user_manager = user_manager + + except Exception: + click.echo("Could not instantiate user manager", err=True) + ctx.exit(-1) + + +@user.command(name="list") +@click.pass_context +def list_users_command(ctx): + """Lists user information""" + users = ctx.obj.user_manager.get_all_users() + _print_list(users) + + +@user.command(name="add") +@click.argument("username", type=click.STRING, required=True) +@click.password_option("--password", "password", help="Password for the user") +@click.option("-g", "--group", "groups", multiple=True, help="Groups to set on the user") +@click.option( + "-p", + "--permission", + "permissions", + multiple=True, + help="Individual permissions to set on the user", +) +@click.option( + "--admin", + "is_admin", + type=click.BOOL, + is_flag=True, + default=False, + help="Adds user to admin group", +) +@click.pass_context +def add_user_command(ctx, username, password, groups, permissions, is_admin): + """Add a new user.""" + if not groups: + groups = [] + + if is_admin: + groups.append(ctx.obj.group_manager.admin_group) + + try: + ctx.obj.user_manager.add_user( + username, password, groups=groups, permissions=permissions, active=True + ) + + user = ctx.obj.user_manager.find_user(username) + if user: + click.echo("User created:") + click.echo("\t{}".format(_user_to_line(user.as_dict()))) + except UserAlreadyExists: + click.echo( + "A user with the name {} does already exist!".format(username), err=True + ) + + +@user.command(name="remove") +@click.argument("username", type=click.STRING) +@click.pass_context +def remove_user_command(ctx, username): + """Remove an existing user.""" + confirm = click.prompt( + "This is will irreversibly destroy the user account! Enter 'yes' to confirm", + type=click.STRING, + ) + + if confirm.lower() == "yes": + ctx.obj.user_manager.remove_user(username) + click.echo("User {} removed.".format(username)) + else: + click.echo("User {} not removed.".format(username)) + + +@user.command(name="password") +@click.argument("username", type=click.STRING) +@click.password_option("--password", "password", help="New password for user") +@click.pass_context +def change_password_command(ctx, username, password): + """Change an existing user's password.""" + try: + ctx.obj.user_manager.change_user_password(username, password) + click.echo("Password changed for user {}.".format(username)) + except UnknownUser: + click.echo("User {} does not exist!".format(username), err=True) + + +@user.command(name="activate") +@click.argument("username", type=click.STRING) +@click.pass_context +def activate_command(ctx, username): + """Activate a user account.""" + try: + ctx.obj.user_manager.change_user_activation(username, True) + click.echo("User {} activated.".format(username)) + + user = ctx.obj.user_manager.find_user(username) + if user: + click.echo("User created:") + click.echo("\t{}".format(_user_to_line(user.asDict()))) + except UnknownUser: + click.echo("User {} does not exist!".format(username), err=True) + + +@user.command(name="deactivate") +@click.argument("username", type=click.STRING) +@click.pass_context +def deactivate_command(ctx, username): + """Activate a user account.""" + try: + ctx.obj.user_manager.change_user_activation(username, False) + click.echo("User {} activated.".format(username)) + + user = ctx.obj.user_manager.find_user(username) + if user: + click.echo("User created:") + click.echo("\t{}".format(_user_to_line(user.asDict()))) + except UnknownUser: + click.echo("User {} does not exist!".format(username), err=True) + + +def _print_list(users): + click.echo("{} users registered in the system:".format(len(users))) + for user in sorted( + map(lambda x: x.as_dict(), users), key=lambda x: sv(x.get("name")) + ): + click.echo("\t{}".format(_user_to_line(user))) + + +def _user_to_line(user): + return ( + "{name}" + "\n\t\tactive: {active}" + "\n\t\tgroups: {groups}" + "\n\t\tpermissions: {permissions}".format( + name=user.get("name"), + active=user.get("active", "False"), + groups=", ".join(user.get("groups", [])), + permissions=", ".join(user.get("permissions", [])), + ) + ) diff --git a/src/octoprint/daemon.py b/src/octoprint/daemon.py index b6888271b0..72cc58109e 100644 --- a/src/octoprint/daemon.py +++ b/src/octoprint/daemon.py @@ -1,175 +1,188 @@ -# coding=utf-8 +from __future__ import absolute_import, division, print_function, unicode_literals + """ Generic linux daemon base class Originally from http://www.jejik.com/articles/2007/02/a_simple_unix_linux_daemon_in_python/#c35 """ -from __future__ import absolute_import, division, print_function -import sys, os, time, signal, io +import io +import os +import signal +import sys +import time + class Daemon: - """ - A generic daemon class. - - Usage: subclass the daemon class and override the run() method. - - If you want to log the output to someplace different that stdout and stderr, - also override the echo() and error() methods. - """ - - def __init__(self, pidfile): - self.pidfile = pidfile - - def _daemonize(self): - """Daemonize class. UNIX double fork mechanism.""" - - self._double_fork() - self._redirect_io() - - # write pidfile - pid = str(os.getpid()) - self.set_pid(pid) - - def _double_fork(self): - try: - pid = os.fork() - if pid > 0: - # exit first parent - sys.exit(0) - except OSError as err: - self.error("First fork failed: {}".format(str(err))) - sys.exit(1) - - # decouple from parent environment - os.chdir('/') - os.setsid() - os.umask(0o002) - - # do second fork - try: - pid = os.fork() - if pid > 0: - # exit from second parent - sys.exit(0) - except OSError as err: - self.error("Second fork failed: {}".format(str(err))) - sys.exit(1) - - def _redirect_io(self): - # redirect standard file descriptors - sys.stdout.flush() - sys.stderr.flush() - si = open(os.devnull, 'r') - so = open(os.devnull, 'a+') - se = open(os.devnull, 'a+') - - os.dup2(si.fileno(), sys.stdin.fileno()) - os.dup2(so.fileno(), sys.stdout.fileno()) - os.dup2(se.fileno(), sys.stderr.fileno()) - - def terminated(self): - self.remove_pidfile() - - def start(self): - """Start the daemon.""" - - # Check for a pidfile to see if the daemon already runs - pid = self.get_pid() - if pid: - self.error("pidfile {} already exist. Is the daemon already running?".format(self.pidfile)) - sys.exit(1) - - self.echo("Starting daemon...") - - # Start the daemon - self._daemonize() - self.run() - - def stop(self, check_running=True): - """Stop the daemon.""" - pid = self.get_pid() - if not pid: - if not check_running: - return - self.error("pidfile {} does not exist. Is the daemon really running?".format(self.pidfile)) - sys.exit(1) - - self.echo("Stopping daemon...") - - # Try killing the daemon process - try: - while 1: - os.kill(pid, signal.SIGTERM) - time.sleep(0.1) - except OSError as err: - e = str(err.args) - if e.find("No such process") > 0: - self.remove_pidfile() - else: - self.error(e) - sys.exit(1) - - def restart(self): - """Restart the daemon.""" - self.stop(check_running=False) - self.start() - - def status(self): - """Prints the daemon status.""" - if self.is_running(): - self.echo("Daemon is running") - else: - self.echo("Daemon is not running") - - def is_running(self): - """Check if a process is running under the specified pid.""" - pid = self.get_pid() - if pid is None: - return False - - try: - os.kill(pid, 0) - except OSError: - try: - self.remove_pidfile() - except: - self.error("Daemon found not running, but could not remove stale pidfile") - return False - else: - return True - - def get_pid(self): - """Get the pid from the pidfile.""" - try: - with open(self.pidfile,'r') as pf: - pid = int(pf.read().strip()) - except (IOError, ValueError): - pid = None - return pid - - def set_pid(self, pid): - """Write the pid to the pidfile.""" - with open(self.pidfile,'w+') as f: - f.write(str(pid) + '\n') - - def remove_pidfile(self): - """Removes the pidfile.""" - if os.path.isfile(self.pidfile): - os.remove(self.pidfile) - - def run(self): - """You should override this method when you subclass Daemon. - - It will be called after the process has been daemonized by - start() or restart().""" - - raise NotImplementedError() - - @classmethod - def echo(cls, line): - print(line) - - @classmethod - def error(cls, line): - print(line, file=sys.stderr) + """ + A generic daemon class. + + Usage: subclass the daemon class and override the run() method. + + If you want to log the output to someplace different that stdout and stderr, + also override the echo() and error() methods. + """ + + def __init__(self, pidfile): + self.pidfile = pidfile + + def _daemonize(self): + """Daemonize class. UNIX double fork mechanism.""" + + self._double_fork() + self._redirect_io() + + # write pidfile + pid = str(os.getpid()) + self.set_pid(pid) + + def _double_fork(self): + try: + pid = os.fork() + if pid > 0: + # exit first parent + sys.exit(0) + except OSError as err: + self.error("First fork failed: {}".format(str(err))) + sys.exit(1) + + # decouple from parent environment + os.chdir("/") + os.setsid() + os.umask(0o002) + + # do second fork + try: + pid = os.fork() + if pid > 0: + # exit from second parent + sys.exit(0) + except OSError as err: + self.error("Second fork failed: {}".format(str(err))) + sys.exit(1) + + def _redirect_io(self): + # redirect standard file descriptors + sys.stdout.flush() + sys.stderr.flush() + si = io.open(os.devnull, "rt", encoding="utf-8") + so = io.open(os.devnull, "at+", encoding="utf-8") + se = io.open(os.devnull, "at+", encoding="utf-8") + + os.dup2(si.fileno(), sys.stdin.fileno()) + os.dup2(so.fileno(), sys.stdout.fileno()) + os.dup2(se.fileno(), sys.stderr.fileno()) + + def terminated(self): + self.remove_pidfile() + + def start(self): + """Start the daemon.""" + + # Check for a pidfile to see if the daemon already runs + pid = self.get_pid() + if pid: + self.error( + "pidfile {} already exist. Is the daemon already running?".format( + self.pidfile + ) + ) + sys.exit(1) + + self.echo("Starting daemon...") + + # Start the daemon + self._daemonize() + self.run() + + def stop(self, check_running=True): + """Stop the daemon.""" + pid = self.get_pid() + if not pid: + if not check_running: + return + self.error( + "pidfile {} does not exist. Is the daemon really running?".format( + self.pidfile + ) + ) + sys.exit(1) + + self.echo("Stopping daemon...") + + # Try killing the daemon process + try: + while 1: + os.kill(pid, signal.SIGTERM) + time.sleep(0.1) + except OSError as err: + e = str(err.args) + if e.find("No such process") > 0: + self.remove_pidfile() + else: + self.error(e) + sys.exit(1) + + def restart(self): + """Restart the daemon.""" + self.stop(check_running=False) + self.start() + + def status(self): + """Prints the daemon status.""" + if self.is_running(): + self.echo("Daemon is running") + else: + self.echo("Daemon is not running") + + def is_running(self): + """Check if a process is running under the specified pid.""" + pid = self.get_pid() + if pid is None: + return False + + try: + os.kill(pid, 0) + except OSError: + try: + self.remove_pidfile() + except Exception: + self.error("Daemon found not running, but could not remove stale pidfile") + return False + else: + return True + + def get_pid(self): + """Get the pid from the pidfile.""" + try: + with io.open(self.pidfile, "rt", encoding="utf-8") as pf: + pid = int(pf.read().strip()) + except (IOError, ValueError): + pid = None + return pid + + def set_pid(self, pid): + """Write the pid to the pidfile.""" + with io.open(self.pidfile, "wt+", encoding="utf-8") as f: + f.write(str(pid) + "\n") + + def remove_pidfile(self): + """Removes the pidfile.""" + if os.path.isfile(self.pidfile): + os.remove(self.pidfile) + + def run(self): + """You should override this method when you subclass Daemon. + + It will be called after the process has been daemonized by + start() or restart().""" + + raise NotImplementedError() + + @classmethod + def echo(cls, line): + print(line) + + @classmethod + def error(cls, line): + print(line, file=sys.stderr) diff --git a/src/octoprint/environment.py b/src/octoprint/environment.py index 2a369d5ae6..530e39829b 100644 --- a/src/octoprint/environment.py +++ b/src/octoprint/environment.py @@ -1,167 +1,195 @@ -# coding=utf-8 -from __future__ import absolute_import +from __future__ import absolute_import, division, print_function, unicode_literals -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2017 The OctoPrint Project - Released under terms of the AGPLv3 License" import copy import logging import os -import platform import sys import threading -import yaml +from importlib.metadata import version as get_version import psutil +import yaml from octoprint.plugin import EnvironmentDetectionPlugin from octoprint.util.platform import get_os +from octoprint.util.version import get_python_version_string -class EnvironmentDetector(object): - def __init__(self, plugin_manager): - self._plugin_manager = plugin_manager - - self._cache = None - self._cache_lock = threading.RLock() - - self._environment_plugins = self._plugin_manager.get_implementations(EnvironmentDetectionPlugin) - - self._logger = logging.getLogger(__name__) - - @property - def environment(self): - with self._cache_lock: - if self._cache is None: - self.run_detection() - return copy.deepcopy(self._cache) - - def run_detection(self, notify_plugins=True): - try: - environment = dict() - environment["os"] = self._detect_os() - environment["python"] = self._detect_python() - environment["hardware"] = self._detect_hardware() - - plugin_result = self._detect_from_plugins() - if plugin_result: - environment["plugins"] = plugin_result - - with self._cache_lock: - self._cache = environment - - if notify_plugins: - self.notify_plugins() - - return environment - except: - self._logger.exception("Unexpected error while detecting environment") - with self._cache_lock: - self._cache = dict() - return self._cache - - def _detect_os(self): - return dict(id=get_os(), - platform=sys.platform) - - def _detect_python(self): - result = dict(version="unknown", - pip="unknown") - - # determine python version - try: - result["version"] = platform.python_version() - except: - self._logger.exception("Error detecting python version") - - # determine if we are running from a virtual environment - try: - if hasattr(sys, "real_prefix") or (hasattr(sys, "base_prefix") and os.path.realpath(sys.prefix) != os.path.realpath(sys.base_prefix)): - result["virtualenv"] = sys.prefix - except: - self._logger.exception("Error detecting whether we are running in a virtual environment") - - # try to find pip version - try: - import pip - result["pip"] = pip.__version__ - except: - self._logger.exception("Error detecting pip version") - - return result - - def _detect_hardware(self): - result = dict(cores="unknown", - freq="unknown", - ram="unknown") - - try: - cores = psutil.cpu_count() - cpu_freq = psutil.cpu_freq() - ram = psutil.virtual_memory() - if cores: - result["cores"] = cores - if cpu_freq and hasattr(cpu_freq, "max"): - result["freq"] = cpu_freq.max - if ram and hasattr(ram, "total"): - result["ram"] = ram.total - except: - self._logger.exception("Error while detecting hardware environment") - - return result - - def _detect_from_plugins(self): - result = dict() - - for implementation in self._environment_plugins: - try: - additional = implementation.get_additional_environment() - if additional is not None and isinstance(additional, dict) and len(additional): - result[implementation._identifier] = additional - except: - self._logger.exception("Error while fetching additional " - "environment data from plugin {}".format(implementation._identifier)) - - return result - - def log_detected_environment(self, only_to_handler=None): - def _log(message, level=logging.INFO): - if only_to_handler is not None: - import octoprint.logging - octoprint.logging.log_to_handler(self._logger, only_to_handler, level, message, []) - else: - self._logger.log(level, message) - - try: - _log(self._format()) - except: - self._logger.exception("Error logging detected environment") - - def _format(self): - with self._cache_lock: - if self._cache is None: - self.run_detection() - environment = copy.deepcopy(self._cache) - - dumped_environment = yaml.safe_dump(environment, - default_flow_style=False, - indent=" ", - allow_unicode=True).strip() - environment_lines = "\n".join(map(lambda l: "| {}".format(l), dumped_environment.split("\n"))) - return u"Detected environment is Python {} under {} ({}). Details:\n{}".format(environment["python"]["version"], - environment["os"]["id"].title(), - environment["os"]["platform"], - environment_lines) - - def notify_plugins(self): - with self._cache_lock: - if self._cache is None: - self.run_detection(notify_plugins=False) - environment = copy.deepcopy(self._cache) - - for implementation in self._environment_plugins: - try: - implementation.on_environment_detected(environment) - except: - self._logger.exception("Error while sending environment " - "detection result to plugin {}".format(implementation._identifier)) +class EnvironmentDetector(object): + def __init__(self, plugin_manager): + self._plugin_manager = plugin_manager + + self._cache = None + self._cache_lock = threading.RLock() + + self._logger = logging.getLogger(__name__) + + try: + self._environment_plugins = self._plugin_manager.get_implementations( + EnvironmentDetectionPlugin + ) + except Exception: + # just in case, see #3100... + self._logger.exception( + "There was an error fetching EnvironmentDetectionPlugins from the plugin manager" + ) + self._environment_plugins = [] + + @property + def environment(self): + with self._cache_lock: + if self._cache is None: + self.run_detection() + return copy.deepcopy(self._cache) + + def run_detection(self, notify_plugins=True): + try: + environment = {} + environment["os"] = self._detect_os() + environment["python"] = self._detect_python() + environment["hardware"] = self._detect_hardware() + + plugin_result = self._detect_from_plugins() + if plugin_result: + environment["plugins"] = plugin_result + + with self._cache_lock: + self._cache = environment + + if notify_plugins: + self.notify_plugins() + + return environment + except Exception: + self._logger.exception("Unexpected error while detecting environment") + with self._cache_lock: + self._cache = {} + return self._cache + + def _detect_os(self): + return { + "id": get_os(), + "platform": sys.platform, + "bits": 64 if sys.maxsize > 2 ** 32 else 32, + } + + def _detect_python(self): + result = {"version": "unknown", "pip": "unknown"} + + # determine python version + try: + result["version"] = get_python_version_string() + except Exception: + self._logger.exception("Error detecting python version") + + # determine if we are running from a virtual environment + try: + if hasattr(sys, "real_prefix") or ( + hasattr(sys, "base_prefix") + and os.path.realpath(sys.prefix) != os.path.realpath(sys.base_prefix) + ): + result["virtualenv"] = sys.prefix + except Exception: + self._logger.exception( + "Error detecting whether we are running in a virtual environment" + ) + + # try to find pip version + try: + result["pip"] = get_version("pip") + except Exception: + self._logger.exception("Error detecting pip version") + + return result + + def _detect_hardware(self): + result = {"cores": "unknown", "freq": "unknown", "ram": "unknown"} + + try: + cores = psutil.cpu_count() + cpu_freq = psutil.cpu_freq() + ram = psutil.virtual_memory() + if cores: + result["cores"] = cores + if cpu_freq and hasattr(cpu_freq, "max"): + result["freq"] = cpu_freq.max + if ram and hasattr(ram, "total"): + result["ram"] = ram.total + except Exception: + self._logger.exception("Error while detecting hardware environment") + + return result + + def _detect_from_plugins(self): + result = {} + + for implementation in self._environment_plugins: + try: + additional = implementation.get_additional_environment() + if ( + additional is not None + and isinstance(additional, dict) + and len(additional) + ): + result[implementation._identifier] = additional + except Exception: + self._logger.exception( + "Error while fetching additional " + "environment data from plugin {}".format(implementation._identifier), + extra={"plugin": implementation._identifier}, + ) + + return result + + def log_detected_environment(self, only_to_handler=None): + def _log(message, level=logging.INFO): + if only_to_handler is not None: + import octoprint.logging + + octoprint.logging.log_to_handler( + self._logger, only_to_handler, level, message, [] + ) + else: + self._logger.log(level, message) + + try: + _log(self._format()) + except Exception: + self._logger.exception("Error logging detected environment") + + def _format(self): + with self._cache_lock: + if self._cache is None: + self.run_detection() + environment = copy.deepcopy(self._cache) + + dumped_environment = yaml.safe_dump( + environment, default_flow_style=False, indent=2, allow_unicode=True + ).strip() + environment_lines = "\n".join( + map(lambda l: "| {}".format(l), dumped_environment.split("\n")) + ) + return "Detected environment is Python {} under {} ({}). Details:\n{}".format( + environment["python"]["version"], + environment["os"]["id"].title(), + environment["os"]["platform"], + environment_lines, + ) + + def notify_plugins(self): + with self._cache_lock: + if self._cache is None: + self.run_detection(notify_plugins=False) + environment = copy.deepcopy(self._cache) + + for implementation in self._environment_plugins: + try: + implementation.on_environment_detected(environment) + except Exception: + self._logger.exception( + "Error while sending environment " + "detection result to plugin {}".format(implementation._identifier) + ) diff --git a/src/octoprint/events.py b/src/octoprint/events.py index b789f05272..4a9f3579c3 100644 --- a/src/octoprint/events.py +++ b/src/octoprint/events.py @@ -1,461 +1,543 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals __author__ = "Gina Häußge , Lars Norpchen" -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" import datetime import logging import subprocess + try: - import queue + import queue except ImportError: - import Queue as queue -import threading + import Queue as queue + import collections +import re +import threading -from octoprint.settings import settings import octoprint.plugin +from octoprint.settings import settings # singleton _instance = None def all_events(): - return [getattr(Events, name) for name in Events.__dict__ if not name.startswith("__")] + return [ + getattr(Events, name) + for name in Events.__dict__ + if not name.startswith("_") and name not in ("register_event",) + ] class Events(object): - # server - STARTUP = "Startup" - SHUTDOWN = "Shutdown" - CONNECTIVITY_CHANGED = "ConnectivityChanged" - - # connect/disconnect to printer - CONNECTING = "Connecting" - CONNECTED = "Connected" - DISCONNECTING = "Disconnecting" - DISCONNECTED = "Disconnected" - - # State changes - PRINTER_STATE_CHANGED = "PrinterStateChanged" - PRINTER_RESET = "PrinterReset" - - # connect/disconnect by client - CLIENT_OPENED = "ClientOpened" - CLIENT_CLOSED = "ClientClosed" - - # File management - UPLOAD = "Upload" - FILE_SELECTED = "FileSelected" - FILE_DESELECTED = "FileDeselected" - UPDATED_FILES = "UpdatedFiles" - METADATA_ANALYSIS_STARTED = "MetadataAnalysisStarted" - METADATA_ANALYSIS_FINISHED = "MetadataAnalysisFinished" - METADATA_STATISTICS_UPDATED = "MetadataStatisticsUpdated" - - FILE_ADDED = "FileAdded" - FILE_REMOVED = "FileRemoved" - FOLDER_ADDED = "FolderAdded" - FOLDER_REMOVED = "FolderRemoved" - - # SD Upload - TRANSFER_STARTED = "TransferStarted" - TRANSFER_DONE = "TransferDone" - TRANSFER_FAILED = "TransferFailed" - - # print job - PRINT_STARTED = "PrintStarted" - PRINT_DONE = "PrintDone" - PRINT_FAILED = "PrintFailed" - PRINT_CANCELLED = "PrintCancelled" - PRINT_PAUSED = "PrintPaused" - PRINT_RESUMED = "PrintResumed" - ERROR = "Error" - - # print/gcode events - POWER_ON = "PowerOn" - POWER_OFF = "PowerOff" - HOME = "Home" - Z_CHANGE = "ZChange" - WAITING = "Waiting" - DWELL = "Dwelling" - COOLING = "Cooling" - ALERT = "Alert" - CONVEYOR = "Conveyor" - EJECT = "Eject" - E_STOP = "EStop" - POSITION_UPDATE = "PositionUpdate" - TOOL_CHANGE = "ToolChange" - REGISTERED_MESSAGE_RECEIVED = "RegisteredMessageReceived" - - # Timelapse - CAPTURE_START = "CaptureStart" - CAPTURE_DONE = "CaptureDone" - CAPTURE_FAILED = "CaptureFailed" - POSTROLL_START = "PostRollStart" - POSTROLL_END = "PostRollEnd" - MOVIE_RENDERING = "MovieRendering" - MOVIE_DONE = "MovieDone" - MOVIE_FAILED = "MovieFailed" - - # Slicing - SLICING_STARTED = "SlicingStarted" - SLICING_DONE = "SlicingDone" - SLICING_FAILED = "SlicingFailed" - SLICING_CANCELLED = "SlicingCancelled" - SLICING_PROFILE_ADDED = "SlicingProfileAdded" - SLICING_PROFILE_MODIFIED = "SlicingProfileModified" - SLICING_PROFILE_DELETED = "SlicingProfileDeleted" - - # Printer Profiles - PRINTER_PROFILE_ADDED = "PrinterProfileAdded" - PRINTER_PROFILE_MODIFIED = "PrinterProfileModified" - PRINTER_PROFILE_DELETED = "PrinterProfileDeleted" - - # Settings - SETTINGS_UPDATED = "SettingsUpdated" + # server + STARTUP = "Startup" + SHUTDOWN = "Shutdown" + CONNECTIVITY_CHANGED = "ConnectivityChanged" + + # connect/disconnect to printer + CONNECTING = "Connecting" + CONNECTED = "Connected" + DISCONNECTING = "Disconnecting" + DISCONNECTED = "Disconnected" + + # State changes + PRINTER_STATE_CHANGED = "PrinterStateChanged" + PRINTER_RESET = "PrinterReset" + + # connect/disconnect by client + CLIENT_OPENED = "ClientOpened" + CLIENT_CLOSED = "ClientClosed" + CLIENT_AUTHED = "ClientAuthed" + CLIENT_DEAUTHED = "ClientDeauthed" + + # user login/logout + USER_LOGGED_IN = "UserLoggedIn" + USER_LOGGED_OUT = "UserLoggedOut" + + # File management + UPLOAD = "Upload" + FILE_SELECTED = "FileSelected" + FILE_DESELECTED = "FileDeselected" + UPDATED_FILES = "UpdatedFiles" + METADATA_ANALYSIS_STARTED = "MetadataAnalysisStarted" + METADATA_ANALYSIS_FINISHED = "MetadataAnalysisFinished" + METADATA_STATISTICS_UPDATED = "MetadataStatisticsUpdated" + + FILE_ADDED = "FileAdded" + FILE_REMOVED = "FileRemoved" + FOLDER_ADDED = "FolderAdded" + FOLDER_REMOVED = "FolderRemoved" + + # SD Upload + TRANSFER_STARTED = "TransferStarted" + TRANSFER_DONE = "TransferDone" + TRANSFER_FAILED = "TransferFailed" + + # print job + PRINT_STARTED = "PrintStarted" + PRINT_DONE = "PrintDone" + PRINT_FAILED = "PrintFailed" + PRINT_CANCELLING = "PrintCancelling" + PRINT_CANCELLED = "PrintCancelled" + PRINT_PAUSED = "PrintPaused" + PRINT_RESUMED = "PrintResumed" + ERROR = "Error" + + # print/gcode events + POWER_ON = "PowerOn" + POWER_OFF = "PowerOff" + HOME = "Home" + Z_CHANGE = "ZChange" + WAITING = "Waiting" + DWELL = "Dwelling" + COOLING = "Cooling" + ALERT = "Alert" + CONVEYOR = "Conveyor" + EJECT = "Eject" + E_STOP = "EStop" + POSITION_UPDATE = "PositionUpdate" + FIRMWARE_DATA = "FirmwareData" + TOOL_CHANGE = "ToolChange" + REGISTERED_MESSAGE_RECEIVED = "RegisteredMessageReceived" + COMMAND_SUPPRESSED = "CommandSuppressed" + INVALID_TOOL_REPORTED = "InvalidToolReported" + FILAMENT_CHANGE = "FilamentChange" + + # Timelapse + CAPTURE_START = "CaptureStart" + CAPTURE_DONE = "CaptureDone" + CAPTURE_FAILED = "CaptureFailed" + POSTROLL_START = "PostRollStart" + POSTROLL_END = "PostRollEnd" + MOVIE_RENDERING = "MovieRendering" + MOVIE_DONE = "MovieDone" + MOVIE_FAILED = "MovieFailed" + + # Slicing + SLICING_STARTED = "SlicingStarted" + SLICING_DONE = "SlicingDone" + SLICING_FAILED = "SlicingFailed" + SLICING_CANCELLED = "SlicingCancelled" + SLICING_PROFILE_ADDED = "SlicingProfileAdded" + SLICING_PROFILE_MODIFIED = "SlicingProfileModified" + SLICING_PROFILE_DELETED = "SlicingProfileDeleted" + + # Printer Profiles + PRINTER_PROFILE_ADDED = "PrinterProfileAdded" + PRINTER_PROFILE_MODIFIED = "PrinterProfileModified" + PRINTER_PROFILE_DELETED = "PrinterProfileDeleted" + + # Settings + SETTINGS_UPDATED = "SettingsUpdated" + + @classmethod + def register_event(cls, event, prefix=None): + name = cls._to_identifier(event) + if prefix: + event = prefix + event + name = cls._to_identifier(prefix) + name + setattr(cls, name, event) + return name, event + + # based on https://stackoverflow.com/a/1176023 + _first_cap_re = re.compile("(.)([A-Z][a-z]+)") + _all_cap_re = re.compile("([a-z0-9])([A-Z])") + + @classmethod + def _to_identifier(cls, name): + s1 = cls._first_cap_re.sub(r"\1_\2", name) + return cls._all_cap_re.sub(r"\1_\2", s1).upper() def eventManager(): - global _instance - if _instance is None: - _instance = EventManager() - return _instance + global _instance + if _instance is None: + _instance = EventManager() + return _instance class EventManager(object): - """ - Handles receiving events and dispatching them to subscribers - """ - - def __init__(self): - self._registeredListeners = collections.defaultdict(list) - self._logger = logging.getLogger(__name__) - - self._startup_signaled = False - self._shutdown_signaled = False - - self._queue = queue.Queue() - self._held_back = queue.Queue() - - self._worker = threading.Thread(target=self._work) - self._worker.daemon = True - self._worker.start() - - def _work(self): - try: - while not self._shutdown_signaled: - event, payload = self._queue.get(True) - if event == Events.SHUTDOWN: - # we've got the shutdown event here, stop event loop processing after this has been processed - self._logger.info("Processing shutdown event, this will be our last event") - self._shutdown_signaled = True - - eventListeners = self._registeredListeners[event] - self._logger.debug("Firing event: %s (Payload: %r)" % (event, payload)) - - for listener in eventListeners: - self._logger.debug("Sending action to %r" % listener) - try: - listener(event, payload) - except: - self._logger.exception("Got an exception while sending event %s (Payload: %r) to %s" % (event, payload, listener)) - - octoprint.plugin.call_plugin(octoprint.plugin.types.EventHandlerPlugin, - "on_event", - args=(event, payload), - initialized=True) - self._logger.info("Event loop shut down") - except: - self._logger.exception("Ooops, the event bus worker loop crashed") - - def fire(self, event, payload=None): - """ - Fire an event to anyone subscribed to it - - Any object can generate an event and any object can subscribe to the event's name as a string (arbitrary, but - case sensitive) and any extra payload data that may pertain to the event. - - Callbacks must implement the signature "callback(event, payload)", with "event" being the event's name and - payload being a payload object specific to the event. - """ - - send_held_back = False - if event == Events.STARTUP: - self._logger.info("Processing startup event, this is our first event") - self._startup_signaled = True - send_held_back = True - - self._enqueue(event, payload) - - if send_held_back: - self._logger.info("Adding {} events to queue that " - "were held back before startup event".format(self._held_back.qsize())) - while True: - try: - self._queue.put(self._held_back.get(block=False)) - except queue.Empty: - break - - def _enqueue(self, event, payload): - if self._startup_signaled: - q = self._queue - else: - q = self._held_back - - q.put((event, payload)) - - if event == Events.UPDATED_FILES and "type" in payload and payload["type"] == "printables": - # when sending UpdatedFiles with type "printables", also send another event with deprecated type "gcode" - # TODO v1.3.0 Remove again - import copy - legacy_payload = copy.deepcopy(payload) - legacy_payload["type"] = "gcode" - q.put((event, legacy_payload)) - - def subscribe(self, event, callback): - """ - Subscribe a listener to an event -- pass in the event name (as a string) and the callback object - """ - - if callback in self._registeredListeners[event]: - # callback is already subscribed to the event - return - - self._registeredListeners[event].append(callback) - self._logger.debug("Subscribed listener %r for event %s" % (callback, event)) - - def unsubscribe (self, event, callback): - """ - Unsubscribe a listener from an event -- pass in the event name (as string) and the callback object - """ - - if not callback in self._registeredListeners[event]: - # callback not subscribed to event, just return - return - - self._registeredListeners[event].remove(callback) - self._logger.debug("Unsubscribed listener %r for event %s" % (callback, event)) - - def join(self, timeout=None): - self._worker.join(timeout) - return self._worker.is_alive() + """ + Handles receiving events and dispatching them to subscribers + """ + + def __init__(self): + self._registeredListeners = collections.defaultdict(list) + self._logger = logging.getLogger(__name__) + self._logger_fire = logging.getLogger("{}.fire".format(__name__)) + + self._startup_signaled = False + self._shutdown_signaled = False + + self._queue = queue.Queue() + self._held_back = queue.Queue() + + self._worker = threading.Thread(target=self._work) + self._worker.daemon = True + self._worker.start() + + def _work(self): + try: + while not self._shutdown_signaled: + event, payload = self._queue.get(True) + if event == Events.SHUTDOWN: + # we've got the shutdown event here, stop event loop processing after this has been processed + self._logger.info( + "Processing shutdown event, this will be our last event" + ) + self._shutdown_signaled = True + + eventListeners = self._registeredListeners[event] + self._logger_fire.debug( + "Firing event: {} (Payload: {!r})".format(event, payload) + ) + + for listener in eventListeners: + self._logger.debug("Sending action to {!r}".format(listener)) + try: + listener(event, payload) + except Exception: + self._logger.exception( + "Got an exception while sending event {} (Payload: {!r}) to {}".format( + event, payload, listener + ) + ) + + octoprint.plugin.call_plugin( + octoprint.plugin.types.EventHandlerPlugin, + "on_event", + args=(event, payload), + ) + self._logger.info("Event loop shut down") + except Exception: + self._logger.exception("Ooops, the event bus worker loop crashed") + + def fire(self, event, payload=None): + """ + Fire an event to anyone subscribed to it + + Any object can generate an event and any object can subscribe to the event's name as a string (arbitrary, but + case sensitive) and any extra payload data that may pertain to the event. + + Callbacks must implement the signature "callback(event, payload)", with "event" being the event's name and + payload being a payload object specific to the event. + """ + + send_held_back = False + if event == Events.STARTUP: + self._logger.info("Processing startup event, this is our first event") + self._startup_signaled = True + send_held_back = True + + self._enqueue(event, payload) + + if send_held_back: + self._logger.info( + "Adding {} events to queue that " + "were held back before startup event".format(self._held_back.qsize()) + ) + while True: + try: + self._queue.put(self._held_back.get(block=False)) + except queue.Empty: + break + + def _enqueue(self, event, payload): + if self._startup_signaled: + q = self._queue + else: + q = self._held_back + + q.put((event, payload)) + + def subscribe(self, event, callback): + """ + Subscribe a listener to an event -- pass in the event name (as a string) and the callback object + """ + + if callback in self._registeredListeners[event]: + # callback is already subscribed to the event + return + + self._registeredListeners[event].append(callback) + self._logger.debug( + "Subscribed listener {!r} for event {}".format(callback, event) + ) + + def unsubscribe(self, event, callback): + """ + Unsubscribe a listener from an event -- pass in the event name (as string) and the callback object + """ + + try: + self._registeredListeners[event].remove(callback) + except ValueError: + # not registered + pass + + def join(self, timeout=None): + self._worker.join(timeout) + return self._worker.is_alive() class GenericEventListener(object): - """ - The GenericEventListener can be subclassed to easily create custom event listeners. - """ + """ + The GenericEventListener can be subclassed to easily create custom event listeners. + """ - def __init__(self): - self._logger = logging.getLogger(__name__) + def __init__(self): + self._logger = logging.getLogger(__name__) - def subscribe(self, events): - """ - Subscribes the eventCallback method for all events in the given list. - """ + def subscribe(self, events): + """ + Subscribes the eventCallback method for all events in the given list. + """ - for event in events: - eventManager().subscribe(event, self.eventCallback) + for event in events: + eventManager().subscribe(event, self.eventCallback) - def unsubscribe(self, events): - """ - Unsubscribes the eventCallback method for all events in the given list - """ + def unsubscribe(self, events): + """ + Unsubscribes the eventCallback method for all events in the given list + """ - for event in events: - eventManager().unsubscribe(event, self.eventCallback) + for event in events: + eventManager().unsubscribe(event, self.eventCallback) - def eventCallback(self, event, payload): - """ - Actual event callback called with name of event and optional payload. Not implemented here, override in - child classes. - """ - pass + def eventCallback(self, event, payload): + """ + Actual event callback called with name of event and optional payload. Not implemented here, override in + child classes. + """ + pass class DebugEventListener(GenericEventListener): - def __init__(self): - GenericEventListener.__init__(self) + def __init__(self): + GenericEventListener.__init__(self) - events = filter(lambda x: not x.startswith("__"), dir(Events)) - self.subscribe(events) + events = list(filter(lambda x: not x.startswith("__"), dir(Events))) + self.subscribe(events) - def eventCallback(self, event, payload): - GenericEventListener.eventCallback(self, event, payload) - self._logger.debug("Received event: %s (Payload: %r)" % (event, payload)) + def eventCallback(self, event, payload): + GenericEventListener.eventCallback(self, event, payload) + self._logger.debug("Received event: {} (Payload: {!r})".format(event, payload)) class CommandTrigger(GenericEventListener): - def __init__(self, printer): - GenericEventListener.__init__(self) - self._printer = printer - self._subscriptions = {} - - self._initSubscriptions() - - def _initSubscriptions(self): - """ - Subscribes all events as defined in "events > $triggerType > subscriptions" in the settings with their - respective commands. - """ - if not settings().get(["events"]): - return - - if not settings().getBoolean(["events", "enabled"]): - return - - eventsToSubscribe = [] - subscriptions = settings().get(["events", "subscriptions"]) - for subscription in subscriptions: - if not isinstance(subscription, dict): - self._logger.info("Invalid subscription definition, not a dictionary: {!r}".format(subscription)) - continue - - if not "event" in subscription.keys() or not "command" in subscription.keys() \ - or not "type" in subscription.keys() or not subscription["type"] in ["system", "gcode"]: - self._logger.info("Invalid command trigger, missing either event, type or command or type is invalid: {!r}".format(subscription)) - continue - - if "enabled" in subscription.keys() and not subscription["enabled"]: - self._logger.info("Disabled command trigger: {!r}".format(subscription)) - continue - - event = subscription["event"] - command = subscription["command"] - commandType = subscription["type"] - debug = subscription["debug"] if "debug" in subscription else False - - if not event in self._subscriptions.keys(): - self._subscriptions[event] = [] - self._subscriptions[event].append((command, commandType, debug)) - - if not event in eventsToSubscribe: - eventsToSubscribe.append(event) - - self.subscribe(eventsToSubscribe) - - def eventCallback(self, event, payload): - """ - Event callback, iterates over all subscribed commands for the given event, processes the command - string and then executes the command via the abstract executeCommand method. - """ - - GenericEventListener.eventCallback(self, event, payload) - - if not event in self._subscriptions: - return - - for command, commandType, debug in self._subscriptions[event]: - try: - if isinstance(command, (tuple, list, set)): - processedCommand = [] - for c in command: - processedCommand.append(self._processCommand(c, payload)) - else: - processedCommand = self._processCommand(command, payload) - self.executeCommand(processedCommand, commandType, debug=debug) - except KeyError as e: - self._logger.warn("There was an error processing one or more placeholders in the following command: %s" % command) - - def executeCommand(self, command, commandType, debug=False): - if commandType == "system": - self._executeSystemCommand(command, debug=debug) - elif commandType == "gcode": - self._executeGcodeCommand(command, debug=debug) - - def _executeSystemCommand(self, command, debug=False): - def commandExecutioner(cmd): - if debug: - self._logger.info("Executing system command: {}".format(cmd)) - else: - self._logger.info("Executing a system command") - # we run this with shell=True since we have to trust whatever - # our admin configured as command and since we want to allow - # shell-alike handling here... - subprocess.check_call(cmd, shell=True) - - def process(): - try: - if isinstance(command, (list, tuple, set)): - for c in command: - commandExecutioner(c) - else: - commandExecutioner(command) - except subprocess.CalledProcessError as e: - if debug: - self._logger.warn("Command failed with return code {}: {}".format(e.returncode, str(e))) - else: - self._logger.warn("Command failed with return code {}, enable debug logging on target 'octoprint.events' for details".format(e.returncode)) - except: - self._logger.exception("Command failed") - - t = threading.Thread(target=process) - t.daemon = True - t.start() - - def _executeGcodeCommand(self, command, debug=False): - commands = [command] - if isinstance(command, (list, tuple, set)): - commands = list(command) - if debug: - self._logger.info("Executing GCode commands: %r" % command) - self._printer.commands(commands) - - def _processCommand(self, command, payload): - """ - Performs string substitutions in the command string based on a few current parameters. - - The following substitutions are currently supported: - - - {__currentZ} : current Z position of the print head, or -1 if not available - - {__filename} : name of currently selected file, or "NO FILE" if no file is selected - - {__filepath} : path in origin location of currently selected file, or "NO FILE" if no file is selected - - {__fileorigin} : origin of currently selected file, or "NO FILE" if no file is selected - - {__progress} : current print progress in percent, 0 if no print is in progress - - {__data} : the string representation of the event's payload - - {__json} : the json representation of the event's payload, "{}" if there is no payload, "" if there was an error on serialization - - {__now} : ISO 8601 representation of the current date and time - - Additionally, the keys of the event's payload can also be used as placeholder. - """ - - json_string = "{}" - if payload: - import json - try: - json_string = json.dumps(payload) - except: - json_string = "" - - params = { - "__currentZ": "-1", - "__filename": "NO FILE", - "__filepath": "NO PATH", - "__progress": "0", - "__data": str(payload), - "__json": json_string, - "__now": datetime.datetime.now().isoformat() - } - - currentData = self._printer.get_current_data() - - if "currentZ" in currentData and currentData["currentZ"] is not None: - params["__currentZ"] = str(currentData["currentZ"]) - - if "job" in currentData and "file" in currentData["job"] and "name" in currentData["job"]["file"] \ - and currentData["job"]["file"]["name"] is not None: - params["__filename"] = currentData["job"]["file"]["name"] - params["__filepath"] = currentData["job"]["file"]["path"] - params["__fileorigin"] = currentData["job"]["file"]["origin"] - if "progress" in currentData and currentData["progress"] is not None \ - and "completion" in currentData["progress"] and currentData["progress"]["completion"] is not None: - params["__progress"] = str(round(currentData["progress"]["completion"])) - - # now add the payload keys as well - if isinstance(payload, dict): - params.update(payload) - - return command.format(**params) + def __init__(self, printer): + GenericEventListener.__init__(self) + self._printer = printer + self._subscriptions = {} + + self._initSubscriptions() + + def _initSubscriptions(self): + """ + Subscribes all events as defined in "events > $triggerType > subscriptions" in the settings with their + respective commands. + """ + if not settings().get(["events"]): + return + + if not settings().getBoolean(["events", "enabled"]): + return + + eventsToSubscribe = [] + subscriptions = settings().get(["events", "subscriptions"]) + for subscription in subscriptions: + if not isinstance(subscription, dict): + self._logger.info( + "Invalid subscription definition, not a dictionary: {!r}".format( + subscription + ) + ) + continue + + if ( + "event" not in subscription + or "command" not in subscription + or "type" not in subscription + or subscription["type"] not in ["system", "gcode"] + ): + self._logger.info( + "Invalid command trigger, missing either event, type or command or type is invalid: {!r}".format( + subscription + ) + ) + continue + + if "enabled" in subscription and not subscription["enabled"]: + self._logger.info("Disabled command trigger: {!r}".format(subscription)) + continue + + events = subscription["event"] + command = subscription["command"] + commandType = subscription["type"] + debug = subscription["debug"] if "debug" in subscription else False + + # "event" in the configuration can be a string, or + # a list of strings. If it's the former, convert it + # into the latter. + if not isinstance(events, (tuple, list, set)): + events = [events] + + for event in events: + if event not in self._subscriptions: + self._subscriptions[event] = [] + self._subscriptions[event].append((command, commandType, debug)) + + if event not in eventsToSubscribe: + eventsToSubscribe.append(event) + + self.subscribe(eventsToSubscribe) + + def eventCallback(self, event, payload): + """ + Event callback, iterates over all subscribed commands for the given event, processes the command + string and then executes the command via the abstract executeCommand method. + """ + + GenericEventListener.eventCallback(self, event, payload) + + if event not in self._subscriptions: + return + + for command, commandType, debug in self._subscriptions[event]: + try: + if isinstance(command, (tuple, list, set)): + processedCommand = [] + for c in command: + processedCommand.append(self._processCommand(c, event, payload)) + else: + processedCommand = self._processCommand(command, event, payload) + self.executeCommand(processedCommand, commandType, debug=debug) + except KeyError: + self._logger.warning( + "There was an error processing one or more placeholders in the following command: %s" + % command + ) + + def executeCommand(self, command, commandType, debug=False): + if commandType == "system": + self._executeSystemCommand(command, debug=debug) + elif commandType == "gcode": + self._executeGcodeCommand(command, debug=debug) + + def _executeSystemCommand(self, command, debug=False): + def commandExecutioner(cmd): + if debug: + self._logger.info("Executing system command: {}".format(cmd)) + else: + self._logger.info("Executing a system command") + # we run this with shell=True since we have to trust whatever + # our admin configured as command and since we want to allow + # shell-alike handling here... + subprocess.check_call(cmd, shell=True) + + def process(): + try: + if isinstance(command, (list, tuple, set)): + for c in command: + commandExecutioner(c) + else: + commandExecutioner(command) + except subprocess.CalledProcessError as e: + if debug: + self._logger.warning( + "Command failed with return code {}: {}".format( + e.returncode, str(e) + ) + ) + else: + self._logger.warning( + "Command failed with return code {}, enable debug logging on target 'octoprint.events' for details".format( + e.returncode + ) + ) + except Exception: + self._logger.exception("Command failed") + + t = threading.Thread(target=process) + t.daemon = True + t.start() + + def _executeGcodeCommand(self, command, debug=False): + commands = [command] + if isinstance(command, (list, tuple, set)): + commands = list(command) + if debug: + self._logger.info("Executing GCode commands: %r" % command) + self._printer.commands(commands) + + def _processCommand(self, command, event, payload): + """ + Performs string substitutions in the command string based on a few current parameters. + + The following substitutions are currently supported: + + - {__currentZ} : current Z position of the print head, or -1 if not available + - {__eventname} : the name of the event hook being triggered + - {__filename} : name of currently selected file, or "NO FILE" if no file is selected + - {__filepath} : path in origin location of currently selected file, or "NO FILE" if no file is selected + - {__fileorigin} : origin of currently selected file, or "NO FILE" if no file is selected + - {__progress} : current print progress in percent, 0 if no print is in progress + - {__data} : the string representation of the event's payload + - {__json} : the json representation of the event's payload, "{}" if there is no payload, "" if there was an error on serialization + - {__now} : ISO 8601 representation of the current date and time + + Additionally, the keys of the event's payload can also be used as placeholder. + """ + + json_string = "{}" + if payload: + import json + + try: + json_string = json.dumps(payload) + except Exception: + self._logger.exception("JSON: Cannot dump %r", payload) + json_string = "" + + params = { + "__currentZ": "-1", + "__eventname": event, + "__filename": "NO FILE", + "__filepath": "NO PATH", + "__progress": "0", + "__data": str(payload), + "__json": json_string, + "__now": datetime.datetime.now().isoformat(), + } + + currentData = self._printer.get_current_data() + + if "currentZ" in currentData and currentData["currentZ"] is not None: + params["__currentZ"] = str(currentData["currentZ"]) + + if ( + "job" in currentData + and "file" in currentData["job"] + and "name" in currentData["job"]["file"] + and currentData["job"]["file"]["name"] is not None + ): + params["__filename"] = currentData["job"]["file"]["name"] + params["__filepath"] = currentData["job"]["file"]["path"] + params["__fileorigin"] = currentData["job"]["file"]["origin"] + if ( + "progress" in currentData + and currentData["progress"] is not None + and "completion" in currentData["progress"] + and currentData["progress"]["completion"] is not None + ): + params["__progress"] = str(round(currentData["progress"]["completion"])) + + # now add the payload keys as well + if isinstance(payload, dict): + params.update(payload) + + return command.format(**params) diff --git a/src/octoprint/filemanager/__init__.py b/src/octoprint/filemanager/__init__.py index 794a7f72a8..7099bd1d7e 100644 --- a/src/octoprint/filemanager/__init__.py +++ b/src/octoprint/filemanager/__init__.py @@ -1,712 +1,1073 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals __author__ = "Gina Häußge " -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" +import io import logging import os +from collections import namedtuple + import octoprint.plugin import octoprint.util +from octoprint.events import Events, eventManager +from octoprint.util import get_fully_qualified_classname as fqcn -from octoprint.events import eventManager, Events - -from .destinations import FileDestinations -from .analysis import QueueEntry, AnalysisQueue -from .storage import LocalFileStorage -from .util import AbstractFileWrapper, StreamWrapper, DiskFileWrapper - -from collections import namedtuple - -from past.builtins import basestring +from .analysis import AnalysisQueue, QueueEntry # noqa: F401 +from .destinations import FileDestinations # noqa: F401 +from .storage import LocalFileStorage # noqa: F401 +from .util import AbstractFileWrapper, DiskFileWrapper, StreamWrapper # noqa: F401 ContentTypeMapping = namedtuple("ContentTypeMapping", "extensions, content_type") ContentTypeDetector = namedtuple("ContentTypeDetector", "extensions, detector") -extensions = dict( -) +extensions = {} + def full_extension_tree(): - result = dict( - # extensions for 3d model files - model=dict( - stl=ContentTypeMapping(["stl"], "application/sla") - ), - # extensions for printable machine code - machinecode=dict( - gcode=ContentTypeMapping(["gcode", "gco", "g"], "text/plain") - ) - ) - - def leaf_merger(a, b): - supported_leaf_types = (ContentTypeMapping, ContentTypeDetector, list) - if not isinstance(a, supported_leaf_types) or not isinstance(b, supported_leaf_types): - raise ValueError() - - if isinstance(a, ContentTypeDetector) and isinstance(b, ContentTypeMapping): - raise ValueError() - - if isinstance(a, ContentTypeMapping) and isinstance(b, ContentTypeDetector): - raise ValueError() - - a_list = a if isinstance(a, list) else a.extensions - b_list = b if isinstance(b, list) else b.extensions - merged = a_list + b_list - - content_type = None - if isinstance(b, ContentTypeMapping): - content_type = b.content_type - elif isinstance(a, ContentTypeMapping): - content_type = a.content_type - - detector = None - if isinstance(b, ContentTypeDetector): - detector = b.detector - elif isinstance(a, ContentTypeDetector): - detector = a.detector - - if content_type is not None: - return ContentTypeMapping(merged, content_type) - elif detector is not None: - return ContentTypeDetector(merged, detector) - else: - return merged - - extension_tree_hooks = octoprint.plugin.plugin_manager().get_hooks("octoprint.filemanager.extension_tree") - for name, hook in extension_tree_hooks.items(): - try: - hook_result = hook() - if hook_result is None or not isinstance(hook_result, dict): - continue - result = octoprint.util.dict_merge(result, hook_result, leaf_merger=leaf_merger) - except: - logging.getLogger(__name__).exception("Exception while retrieving additional extension tree entries from hook {name}".format(name=name)) - - return result + result = { + "machinecode": {"gcode": ContentTypeMapping(["gcode", "gco", "g"], "text/plain")} + } + + def leaf_merger(a, b): + supported_leaf_types = (ContentTypeMapping, ContentTypeDetector, list) + if not isinstance(a, supported_leaf_types) or not isinstance( + b, supported_leaf_types + ): + raise ValueError() + + if isinstance(a, ContentTypeDetector) and isinstance(b, ContentTypeMapping): + raise ValueError() + + if isinstance(a, ContentTypeMapping) and isinstance(b, ContentTypeDetector): + raise ValueError() + + a_list = a if isinstance(a, list) else a.extensions + b_list = b if isinstance(b, list) else b.extensions + merged = a_list + b_list + + content_type = None + if isinstance(b, ContentTypeMapping): + content_type = b.content_type + elif isinstance(a, ContentTypeMapping): + content_type = a.content_type + + detector = None + if isinstance(b, ContentTypeDetector): + detector = b.detector + elif isinstance(a, ContentTypeDetector): + detector = a.detector + + if content_type is not None: + return ContentTypeMapping(merged, content_type) + elif detector is not None: + return ContentTypeDetector(merged, detector) + else: + return merged + + slicer_plugins = octoprint.plugin.plugin_manager().get_implementations( + octoprint.plugin.SlicerPlugin + ) + for plugin in slicer_plugins: + try: + plugin_result = plugin.get_slicer_extension_tree() + if plugin_result is None or not isinstance(plugin_result, dict): + continue + octoprint.util.dict_merge( + result, plugin_result, leaf_merger=leaf_merger, in_place=True + ) + except Exception: + logging.getLogger(__name__).exception( + "Exception while retrieving additional extension " + "tree entries from SlicerPlugin {name}".format(name=plugin._identifier), + extra={"plugin": plugin._identifier}, + ) + + extension_tree_hooks = octoprint.plugin.plugin_manager().get_hooks( + "octoprint.filemanager.extension_tree" + ) + for name, hook in extension_tree_hooks.items(): + try: + hook_result = hook() + if hook_result is None or not isinstance(hook_result, dict): + continue + result = octoprint.util.dict_merge( + result, hook_result, leaf_merger=leaf_merger, in_place=True + ) + except Exception: + logging.getLogger(__name__).exception( + "Exception while retrieving additional extension " + "tree entries from hook {name}".format(name=name), + extra={"plugin": name}, + ) + + return result + def get_extensions(type, subtree=None): - if not subtree: - subtree = full_extension_tree() + if subtree is None: + subtree = full_extension_tree() - for key, value in subtree.items(): - if key == type: - return get_all_extensions(subtree=value) - elif isinstance(value, dict): - sub_extensions = get_extensions(type, subtree=value) - if sub_extensions: - return sub_extensions + for key, value in subtree.items(): + if key == type: + return get_all_extensions(subtree=value) + elif isinstance(value, dict): + sub_extensions = get_extensions(type, subtree=value) + if sub_extensions: + return sub_extensions + + return None - return None def get_all_extensions(subtree=None): - if not subtree: - subtree = full_extension_tree() - - result = [] - if isinstance(subtree, dict): - for key, value in subtree.items(): - if isinstance(value, dict): - result += get_all_extensions(value) - elif isinstance(value, (ContentTypeMapping, ContentTypeDetector)): - result += value.extensions - elif isinstance(value, (list, tuple)): - result += value - elif isinstance(subtree, (ContentTypeMapping, ContentTypeDetector)): - result = subtree.extensions - elif isinstance(subtree, (list, tuple)): - result = subtree - return result + if subtree is None: + subtree = full_extension_tree() + + result = [] + if isinstance(subtree, dict): + for value in subtree.values(): + if isinstance(value, dict): + result += get_all_extensions(value) + elif isinstance(value, (ContentTypeMapping, ContentTypeDetector)): + result += value.extensions + elif isinstance(value, (list, tuple)): + result += value + elif isinstance(subtree, (ContentTypeMapping, ContentTypeDetector)): + result = subtree.extensions + elif isinstance(subtree, (list, tuple)): + result = subtree + return result + def get_path_for_extension(extension, subtree=None): - if not subtree: - subtree = full_extension_tree() + if subtree is None: + subtree = full_extension_tree() - for key, value in subtree.items(): - if isinstance(value, (ContentTypeMapping, ContentTypeDetector)) and extension in value.extensions: - return [key] - elif isinstance(value, (list, tuple)) and extension in value: - return [key] - elif isinstance(value, dict): - path = get_path_for_extension(extension, subtree=value) - if path: - return [key] + path + for key, value in subtree.items(): + if ( + isinstance(value, (ContentTypeMapping, ContentTypeDetector)) + and extension in value.extensions + ): + return [key] + elif isinstance(value, (list, tuple)) and extension in value: + return [key] + elif isinstance(value, dict): + path = get_path_for_extension(extension, subtree=value) + if path: + return [key] + path + + return None - return None def get_content_type_mapping_for_extension(extension, subtree=None): - if not subtree: - subtree = full_extension_tree() + if subtree is None: + subtree = full_extension_tree() + + for value in subtree.values(): + content_extension_matches = ( + isinstance(value, (ContentTypeMapping, ContentTypeDetector)) + and extension in value.extensions + ) + list_extension_matches = isinstance(value, (list, tuple)) and extension in value - for key, value in subtree.items(): - content_extension_matches = isinstance(value, (ContentTypeMapping, ContentTypeDetector)) and extension in value. extensions - list_extension_matches = isinstance(value, (list, tuple)) and extension in value + if content_extension_matches or list_extension_matches: + return value + elif isinstance(value, dict): + result = get_content_type_mapping_for_extension(extension, subtree=value) + if result is not None: + return result - if content_extension_matches or list_extension_matches: - return value - elif isinstance(value, dict): - result = get_content_type_mapping_for_extension(extension, subtree=value) - if result is not None: - return result + return None - return None def valid_extension(extension, type=None): - if not type: - return extension in get_all_extensions() - else: - extensions = get_extensions(type) - if extensions: - return extension in extensions + if not type: + return extension in get_all_extensions() + else: + extensions = get_extensions(type) + if extensions: + return extension in extensions + def valid_file_type(filename, type=None): - _, extension = os.path.splitext(filename) - extension = extension[1:].lower() - return valid_extension(extension, type=type) + _, extension = os.path.splitext(filename) + extension = extension[1:].lower() + return valid_extension(extension, type=type) + def get_file_type(filename): - _, extension = os.path.splitext(filename) - extension = extension[1:].lower() - return get_path_for_extension(extension) + _, extension = os.path.splitext(filename) + extension = extension[1:].lower() + return get_path_for_extension(extension) + def get_mime_type(filename): - _, extension = os.path.splitext(filename) - extension = extension[1:].lower() - mapping = get_content_type_mapping_for_extension(extension) - if mapping: - if isinstance(mapping, ContentTypeMapping) and mapping.content_type is not None: - return mapping.content_type - elif isinstance(mapping, ContentTypeDetector) and callable(mapping.detector): - result = mapping.detector(filename) - if result is not None: - return result - return "application/octet-stream" + _, extension = os.path.splitext(filename) + extension = extension[1:].lower() + mapping = get_content_type_mapping_for_extension(extension) + if mapping: + if isinstance(mapping, ContentTypeMapping) and mapping.content_type is not None: + return mapping.content_type + elif isinstance(mapping, ContentTypeDetector) and callable(mapping.detector): + result = mapping.detector(filename) + if result is not None: + return result + return "application/octet-stream" class NoSuchStorage(Exception): - pass + pass class FileManager(object): - def __init__(self, analysis_queue, slicing_manager, printer_profile_manager, initial_storage_managers=None): - self._logger = logging.getLogger(__name__) - self._analysis_queue = analysis_queue - self._analysis_queue.register_finish_callback(self._on_analysis_finished) - - self._storage_managers = dict() - if initial_storage_managers: - self._storage_managers.update(initial_storage_managers) - - self._slicing_manager = slicing_manager - self._printer_profile_manager = printer_profile_manager - - import threading - self._slicing_jobs = dict() - self._slicing_jobs_mutex = threading.Lock() - - self._slicing_progress_callbacks = [] - self._last_slicing_progress = None - - self._progress_plugins = [] - self._preprocessor_hooks = dict() - - import octoprint.settings - self._recovery_file = os.path.join(octoprint.settings.settings().getBaseFolder("data"), "print_recovery_data.yaml") - - def initialize(self, process_backlog=False): - self.reload_plugins() - if process_backlog: - self.process_backlog() - - def process_backlog(self): - def worker(): - self._logger.info("Adding backlog items from all storage types to analysis queue...".format(**locals())) - for storage_type, storage_manager in self._storage_managers.items(): - self._determine_analysis_backlog(storage_type, storage_manager) - - import threading - thread = threading.Thread(target=worker) - thread.daemon = True - thread.start() - - def reload_plugins(self): - self._progress_plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.ProgressPlugin) - self._preprocessor_hooks = octoprint.plugin.plugin_manager().get_hooks("octoprint.filemanager.preprocessor") - - def register_slicingprogress_callback(self, callback): - self._slicing_progress_callbacks.append(callback) - - def unregister_slicingprogress_callback(self, callback): - try: - self._slicing_progress_callbacks.remove(callback) - except ValueError: - # callback was not registered - pass - - def _determine_analysis_backlog(self, storage_type, storage_manager, root=None, high_priority=False): - counter = 0 - - backlog_generator = storage_manager.analysis_backlog - if root is not None: - backlog_generator = storage_manager.analysis_backlog_for_path(path=root) - - for entry, path, printer_profile in backlog_generator: - file_type = get_file_type(path)[-1] - file_name = storage_manager.split_path(path) - - # we'll use the default printer profile for the backlog since we don't know better - queue_entry = QueueEntry(file_name, entry, file_type, storage_type, path, self._printer_profile_manager.get_default()) - if self._analysis_queue.enqueue(queue_entry, high_priority=high_priority): - counter += 1 - - if root: - self._logger.info("Added {counter} items from storage type \"{storage_type}\" and root \"{root}\" to analysis queue".format(**locals())) - else: - self._logger.info("Added {counter} items from storage type \"{storage_type}\" to analysis queue".format(**locals())) - - def add_storage(self, storage_type, storage_manager): - self._storage_managers[storage_type] = storage_manager - self._determine_analysis_backlog(storage_type, storage_manager) - - def remove_storage(self, type): - if not type in self._storage_managers: - return - del self._storage_managers[type] - - @property - def registered_storages(self): - return list(self._storage_managers.keys()) - - @property - def slicing_enabled(self): - return self._slicing_manager.slicing_enabled - - @property - def registered_slicers(self): - return self._slicing_manager.registered_slicers - - @property - def default_slicer(self): - return self._slicing_manager.default_slicer - - def analyse(self, destination, path, printer_profile_id=None): - if not self.file_exists(destination, path): - return - - if printer_profile_id is None: - printer_profile = self._printer_profile_manager.get_current_or_default() - else: - printer_profile = self._printer_profile_manager.get(printer_profile_id) - if printer_profile is None: - printer_profile = self._printer_profile_manager.get_current_or_default() - - queue_entry = self._analysis_queue_entry(destination, path) - self._analysis_queue.dequeue(queue_entry) - - queue_entry = self._analysis_queue_entry(destination, path, printer_profile=printer_profile) - if queue_entry: - return self._analysis_queue.enqueue(queue_entry, high_priority=True) - - return False - - def slice(self, slicer_name, source_location, source_path, dest_location, dest_path, - position=None, profile=None, printer_profile_id=None, overrides=None, display=None, - callback=None, callback_args=None): - absolute_source_path = self.path_on_disk(source_location, source_path) - - def stlProcessed(source_location, source_path, tmp_path, dest_location, dest_path, start_time, - printer_profile_id, callback, callback_args, _error=None, _cancelled=False, _analysis=None): - try: - if _error: - eventManager().fire(Events.SLICING_FAILED, dict(stl=source_path, - stl_location=source_location, - gcode=dest_path, - gcode_location=dest_location, - reason=_error)) - elif _cancelled: - eventManager().fire(Events.SLICING_CANCELLED, dict(stl=source_path, - stl_location=source_location, - gcode=dest_path, - gcode_location=dest_location)) - else: - source_meta = self.get_metadata(source_location, source_path) - hash = source_meta["hash"] - - import io - links = [("model", dict(name=source_path))] - _, stl_name = self.split_path(source_location, source_path) - file_obj = StreamWrapper(os.path.basename(dest_path), - io.BytesIO(u";Generated from {stl_name} {hash}\n".format(**locals()).encode("ascii", "replace")), - io.FileIO(tmp_path, "rb")) - - printer_profile = self._printer_profile_manager.get(printer_profile_id) - self.add_file(dest_location, dest_path, file_obj, - display=display, links=links, allow_overwrite=True, - printer_profile=printer_profile, analysis=_analysis) - - end_time = time.time() - eventManager().fire(Events.SLICING_DONE, dict(stl=source_path, - stl_location=source_location, - gcode=dest_path, - gcode_location=dest_location, - time=end_time - start_time)) - - if callback is not None: - if callback_args is None: - callback_args = () - callback(*callback_args) - finally: - os.remove(tmp_path) - - source_job_key = (source_location, source_path) - dest_job_key = (dest_location, dest_path) - - with self._slicing_jobs_mutex: - if source_job_key in self._slicing_jobs: - del self._slicing_jobs[source_job_key] - if dest_job_key in self._slicing_jobs: - del self._slicing_jobs[dest_job_key] - - slicer = self._slicing_manager.get_slicer(slicer_name) - - import time - start_time = time.time() - eventManager().fire(Events.SLICING_STARTED, {"stl": source_path, - "stl_location": source_location, - "gcode": dest_path, - "gcode_location": dest_location, - "progressAvailable": slicer.get_slicer_properties().get("progress_report", False) if slicer else False}) - - import tempfile - f = tempfile.NamedTemporaryFile(suffix=".gco", delete=False) - temp_path = f.name - f.close() - - with self._slicing_jobs_mutex: - source_job_key = (source_location, source_path) - dest_job_key = (dest_location, dest_path) - if dest_job_key in self._slicing_jobs: - job_slicer_name, job_absolute_source_path, job_temp_path = self._slicing_jobs[dest_job_key] - - self._slicing_manager.cancel_slicing(job_slicer_name, job_absolute_source_path, job_temp_path) - del self._slicing_jobs[dest_job_key] - - self._slicing_jobs[dest_job_key] = self._slicing_jobs[source_job_key] = (slicer_name, absolute_source_path, temp_path) - - args = (source_location, source_path, temp_path, dest_location, dest_path, start_time, printer_profile_id, callback, callback_args) - self._slicing_manager.slice(slicer_name, - absolute_source_path, - temp_path, - profile, - stlProcessed, - position=position, - callback_args=args, - overrides=overrides, - printer_profile_id=printer_profile_id, - on_progress=self.on_slicing_progress, - on_progress_args=(slicer_name, source_location, source_path, dest_location, dest_path)) - - def on_slicing_progress(self, slicer, source_location, source_path, dest_location, dest_path, _progress=None): - if not _progress: - return - - progress_int = int(_progress * 100) - if self._last_slicing_progress != progress_int: - self._last_slicing_progress = progress_int - for callback in self._slicing_progress_callbacks: - try: callback.sendSlicingProgress(slicer, source_location, source_path, dest_location, dest_path, progress_int) - except: self._logger.exception("Exception while pushing slicing progress") - - if progress_int: - def call_plugins(slicer, source_location, source_path, dest_location, dest_path, progress): - for plugin in self._progress_plugins: - try: - plugin.on_slicing_progress(slicer, source_location, source_path, dest_location, dest_path, progress) - except: - self._logger.exception("Exception while sending slicing progress to plugin %s" % plugin._identifier) - - import threading - thread = threading.Thread(target=call_plugins, args=(slicer, source_location, source_path, dest_location, dest_path, progress_int)) - thread.daemon = False - thread.start() - - - def get_busy_files(self): - return self._slicing_jobs.keys() - - def file_in_path(self, destination, path, file): - return self._storage(destination).file_in_path(path, file) - - def file_exists(self, destination, path): - return self._storage(destination).file_exists(path) - - def folder_exists(self, destination, path): - return self._storage(destination).folder_exists(path) - - def list_files(self, destinations=None, path=None, filter=None, recursive=None): - if not destinations: - destinations = self._storage_managers.keys() - if isinstance(destinations, (str, unicode, basestring)): - destinations = [destinations] - - result = dict() - for dst in destinations: - result[dst] = self._storage_managers[dst].list_files(path=path, filter=filter, recursive=recursive) - return result - - def add_file(self, destination, path, file_object, links=None, allow_overwrite=False, printer_profile=None, analysis=None, display=None): - if printer_profile is None: - printer_profile = self._printer_profile_manager.get_current_or_default() - - for hook in self._preprocessor_hooks.values(): - try: - hook_file_object = hook(path, file_object, links=links, printer_profile=printer_profile, allow_overwrite=allow_overwrite) - except: - self._logger.exception("Error when calling preprocessor hook {}, ignoring".format(hook)) - continue - - if hook_file_object is not None: - file_object = hook_file_object - - queue_entry = self._analysis_queue_entry(destination, path) - self._analysis_queue.dequeue(queue_entry) - - path_in_storage = self._storage(destination).add_file(path, file_object, links=links, printer_profile=printer_profile, allow_overwrite=allow_overwrite, display=display) - - if analysis is None: - queue_entry = self._analysis_queue_entry(destination, path_in_storage, printer_profile=printer_profile) - if queue_entry: - self._analysis_queue.enqueue(queue_entry, high_priority=True) - else: - self._add_analysis_result(destination, path, analysis) - - _, name = self._storage(destination).split_path(path_in_storage) - eventManager().fire(Events.FILE_ADDED, dict(storage=destination, - path=path_in_storage, - name=name, - type=get_file_type(name))) - eventManager().fire(Events.UPDATED_FILES, dict(type="printables")) - return path_in_storage - - def remove_file(self, destination, path): - queue_entry = self._analysis_queue_entry(destination, path) - self._analysis_queue.dequeue(queue_entry) - self._storage(destination).remove_file(path) - - _, name = self._storage(destination).split_path(path) - eventManager().fire(Events.FILE_REMOVED, dict(storage=destination, - path=path, - name=name, - type=get_file_type(name))) - eventManager().fire(Events.UPDATED_FILES, dict(type="printables")) - - def copy_file(self, destination, source, dst): - path_in_storage = self._storage(destination).copy_file(source, dst) - if not self.has_analysis(destination, path_in_storage): - queue_entry = self._analysis_queue_entry(destination, path_in_storage) - if queue_entry: - self._analysis_queue.enqueue(queue_entry) - - _, name = self._storage(destination).split_path(path_in_storage) - eventManager().fire(Events.FILE_ADDED, dict(storage=destination, - path=path_in_storage, - name=name, - type=get_file_type(name))) - eventManager().fire(Events.UPDATED_FILES, dict(type="printables")) - - def move_file(self, destination, source, dst): - queue_entry = self._analysis_queue_entry(destination, source) - self._analysis_queue.dequeue(queue_entry) - path = self._storage(destination).move_file(source, dst) - if not self.has_analysis(destination, path): - queue_entry = self._analysis_queue_entry(destination, path) - if queue_entry: - self._analysis_queue.enqueue(queue_entry) - - source_path_in_storage = self._storage(destination).path_in_storage(source) - _, source_name = self._storage(destination).split_path(source_path_in_storage) - dst_path_in_storage = self._storage(destination).path_in_storage(dst) - _, dst_name = self._storage(destination).split_path(dst_path_in_storage) - - eventManager().fire(Events.FILE_REMOVED, dict(storage=destination, - path=source_path_in_storage, - name=source_name, - type=get_file_type(source_name))) - eventManager().fire(Events.FILE_ADDED, dict(storage=destination, - path=dst_path_in_storage, - name=dst_name, - type=get_file_type(dst_name))) - eventManager().fire(Events.UPDATED_FILES, dict(type="printables")) - - def add_folder(self, destination, path, ignore_existing=True, display=None): - path_in_storage = self._storage(destination).add_folder(path, ignore_existing=ignore_existing, display=display) - - _, name = self._storage(destination).split_path(path_in_storage) - eventManager().fire(Events.FOLDER_ADDED, dict(storage=destination, - path=path_in_storage, - name=name)) - eventManager().fire(Events.UPDATED_FILES, dict(type="printables")) - return path_in_storage - - def remove_folder(self, destination, path, recursive=True): - self._analysis_queue.dequeue_folder(destination, path) - self._analysis_queue.pause() - self._storage(destination).remove_folder(path, recursive=recursive) - self._analysis_queue.resume() - - _, name = self._storage(destination).split_path(path) - eventManager().fire(Events.FOLDER_REMOVED, dict(storage=destination, - path=path, - name=name)) - eventManager().fire(Events.UPDATED_FILES, dict(type="printables")) - - def copy_folder(self, destination, source, dst): - path_in_storage = self._storage(destination).copy_folder(source, dst) - self._determine_analysis_backlog(destination, self._storage(destination), root=path_in_storage) - - _, name = self._storage(destination).split_path(path_in_storage) - eventManager().fire(Events.FOLDER_ADDED, dict(storage=destination, - path=path_in_storage, - name=name)) - eventManager().fire(Events.UPDATED_FILES, dict(type="printables")) - - def move_folder(self, destination, source, dst): - self._analysis_queue.dequeue_folder(destination, source) - self._analysis_queue.pause() - dst_path_in_storage = self._storage(destination).move_folder(source, dst) - self._determine_analysis_backlog(destination, self._storage(destination), root=dst_path_in_storage) - self._analysis_queue.resume() - - source_path_in_storage = self._storage(destination).path_in_storage(source) - _, source_name = self._storage(destination).split_path(source_path_in_storage) - _, dst_name = self._storage(destination).split_path(dst_path_in_storage) - - eventManager().fire(Events.FOLDER_REMOVED, dict(storage=destination, - path=source_path_in_storage, - name=source_name)) - eventManager().fire(Events.FOLDER_ADDED, dict(storage=destination, - path=dst_path_in_storage, - name=dst_name)) - eventManager().fire(Events.UPDATED_FILES, dict(type="printables")) - - def has_analysis(self, destination, path): - return self._storage(destination).has_analysis(path) - - def get_metadata(self, destination, path): - return self._storage(destination).get_metadata(path) - - def add_link(self, destination, path, rel, data): - self._storage(destination).add_link(path, rel, data) - - def remove_link(self, destination, path, rel, data): - self._storage(destination).remove_link(path, rel, data) - - def log_print(self, destination, path, timestamp, print_time, success, printer_profile): - try: - if success: - self._storage(destination).add_history(path, dict(timestamp=timestamp, printTime=print_time, success=success, printerProfile=printer_profile)) - else: - self._storage(destination).add_history(path, dict(timestamp=timestamp, success=success, printerProfile=printer_profile)) - eventManager().fire(Events.METADATA_STATISTICS_UPDATED, dict(storage=destination, path=path)) - except NoSuchStorage: - # if there's no storage configured where to log the print, we'll just not log it - pass - - def save_recovery_data(self, origin, path, pos): - import time - import yaml - from octoprint.util import atomic_write - - data = dict(origin=origin, - path=self.path_in_storage(origin, path), - pos=pos, - date=time.time()) - try: - with atomic_write(self._recovery_file, max_permissions=0o666) as f: - yaml.safe_dump(data, stream=f, default_flow_style=False, indent=" ", allow_unicode=True) - except: - self._logger.exception("Could not write recovery data to file {}".format(self._recovery_file)) - - def delete_recovery_data(self): - if not os.path.isfile(self._recovery_file): - return - - try: - os.remove(self._recovery_file) - except: - self._logger.exception("Error deleting recovery data file {}".format(self._recovery_file)) - - def get_recovery_data(self): - if not os.path.isfile(self._recovery_file): - return None - - import yaml - try: - with open(self._recovery_file) as f: - data = yaml.safe_load(f) - return data - except: - self._logger.exception("Could not read recovery data from file {}".format(self._recovery_file)) - self.delete_recovery_data() - - def set_additional_metadata(self, destination, path, key, data, overwrite=False, merge=False): - self._storage(destination).set_additional_metadata(path, key, data, overwrite=overwrite, merge=merge) - - def remove_additional_metadata(self, destination, path, key): - self._storage(destination).remove_additional_metadata(path, key) - - def path_on_disk(self, destination, path): - return self._storage(destination).path_on_disk(path) - - def canonicalize(self, destination, path): - return self._storage(destination).canonicalize(path) - - def sanitize(self, destination, path): - return self._storage(destination).sanitize(path) - - def sanitize_name(self, destination, name): - return self._storage(destination).sanitize_name(name) - - def sanitize_path(self, destination, path): - return self._storage(destination).sanitize_path(path) - - def split_path(self, destination, path): - return self._storage(destination).split_path(path) - - def join_path(self, destination, *path): - return self._storage(destination).join_path(*path) - - def path_in_storage(self, destination, path): - return self._storage(destination).path_in_storage(path) - - def last_modified(self, destination, path=None, recursive=False): - return self._storage(destination).last_modified(path=path, recursive=recursive) - - def _storage(self, destination): - if not destination in self._storage_managers: - raise NoSuchStorage("No storage configured for destination {destination}".format(**locals())) - return self._storage_managers[destination] - - def _add_analysis_result(self, destination, path, result): - if not destination in self._storage_managers: - return - - storage_manager = self._storage_managers[destination] - storage_manager.set_additional_metadata(path, "analysis", result, overwrite=True) - - def _on_analysis_finished(self, entry, result): - self._add_analysis_result(entry.location, entry.path, result) - - def _analysis_queue_entry(self, destination, path, printer_profile=None): - if printer_profile is None: - printer_profile = self._printer_profile_manager.get_current_or_default() - - absolute_path = self._storage(destination).path_on_disk(path) - _, file_name = self._storage(destination).split_path(path) - file_type = get_file_type(absolute_path) - - if file_type: - return QueueEntry(file_name, path, file_type[-1], destination, absolute_path, printer_profile) - else: - return None + def __init__( + self, + analysis_queue, + slicing_manager, + printer_profile_manager, + initial_storage_managers=None, + ): + self._logger = logging.getLogger(__name__) + self._analysis_queue = analysis_queue + self._analysis_queue.register_finish_callback(self._on_analysis_finished) + + self._storage_managers = {} + if initial_storage_managers: + self._storage_managers.update(initial_storage_managers) + + self._slicing_manager = slicing_manager + self._printer_profile_manager = printer_profile_manager + + import threading + + self._slicing_jobs = {} + self._slicing_jobs_mutex = threading.Lock() + + self._slicing_progress_callbacks = [] + self._last_slicing_progress = None + + self._progress_plugins = [] + self._preprocessor_hooks = {} + + import octoprint.settings + + self._recovery_file = os.path.join( + octoprint.settings.settings().getBaseFolder("data"), + "print_recovery_data.yaml", + ) + self._analyzeGcode = octoprint.settings.settings().get(["gcodeAnalysis", "runAt"]) + + def initialize(self, process_backlog=False): + self.reload_plugins() + if process_backlog: + self.process_backlog() + + def process_backlog(self): + # only check for a backlog if gcodeAnalysis is 'idle' or 'always' + if self._analyzeGcode == "never": + return + + def worker(): + self._logger.info( + "Adding backlog items from all storage types to analysis queue...".format( + **locals() + ) + ) + for storage_type, storage_manager in self._storage_managers.items(): + self._determine_analysis_backlog(storage_type, storage_manager) + + import threading + + thread = threading.Thread(target=worker) + thread.daemon = True + thread.start() + + def reload_plugins(self): + self._progress_plugins = octoprint.plugin.plugin_manager().get_implementations( + octoprint.plugin.ProgressPlugin + ) + self._preprocessor_hooks = octoprint.plugin.plugin_manager().get_hooks( + "octoprint.filemanager.preprocessor" + ) + + def register_slicingprogress_callback(self, callback): + self._slicing_progress_callbacks.append(callback) + + def unregister_slicingprogress_callback(self, callback): + try: + self._slicing_progress_callbacks.remove(callback) + except ValueError: + # callback was not registered + pass + + def _determine_analysis_backlog( + self, storage_type, storage_manager, root=None, high_priority=False + ): + counter = 0 + + backlog_generator = storage_manager.analysis_backlog + if root is not None: + backlog_generator = storage_manager.analysis_backlog_for_path(path=root) + + for entry, path, _ in backlog_generator: + file_type = get_file_type(path)[-1] + file_name = storage_manager.split_path(path) + + # we'll use the default printer profile for the backlog since we don't know better + queue_entry = QueueEntry( + file_name, + entry, + file_type, + storage_type, + path, + self._printer_profile_manager.get_default(), + None, + ) + if self._analysis_queue.enqueue(queue_entry, high_priority=high_priority): + counter += 1 + + if root: + self._logger.info( + 'Added {counter} items from storage type "{storage_type}" and root "{root}" to analysis queue'.format( + **locals() + ) + ) + else: + self._logger.info( + 'Added {counter} items from storage type "{storage_type}" to analysis queue'.format( + **locals() + ) + ) + + def add_storage(self, storage_type, storage_manager): + self._storage_managers[storage_type] = storage_manager + self._determine_analysis_backlog(storage_type, storage_manager) + + def remove_storage(self, type): + if type not in self._storage_managers: + return + del self._storage_managers[type] + + @property + def registered_storages(self): + return list(self._storage_managers.keys()) + + @property + def slicing_enabled(self): + return self._slicing_manager.slicing_enabled + + @property + def registered_slicers(self): + return self._slicing_manager.registered_slicers + + @property + def default_slicer(self): + return self._slicing_manager.default_slicer + + def analyse(self, destination, path, printer_profile_id=None): + if not self.file_exists(destination, path): + return + + if printer_profile_id is None: + printer_profile = self._printer_profile_manager.get_current_or_default() + else: + printer_profile = self._printer_profile_manager.get(printer_profile_id) + if printer_profile is None: + printer_profile = self._printer_profile_manager.get_current_or_default() + + queue_entry = self._analysis_queue_entry(destination, path) + self._analysis_queue.dequeue(queue_entry) + + queue_entry = self._analysis_queue_entry( + destination, path, printer_profile=printer_profile + ) + if queue_entry: + return self._analysis_queue.enqueue(queue_entry, high_priority=True) + + return False + + def slice( + self, + slicer_name, + source_location, + source_path, + dest_location, + dest_path, + position=None, + profile=None, + printer_profile_id=None, + overrides=None, + display=None, + callback=None, + callback_args=None, + ): + absolute_source_path = self.path_on_disk(source_location, source_path) + + def stlProcessed( + source_location, + source_path, + tmp_path, + dest_location, + dest_path, + start_time, + printer_profile_id, + callback, + callback_args, + _error=None, + _cancelled=False, + _analysis=None, + ): + try: + if _error: + eventManager().fire( + Events.SLICING_FAILED, + { + "slicer": slicer_name, + "stl": source_path, + "stl_location": source_location, + "gcode": dest_path, + "gcode_location": dest_location, + "reason": _error, + }, + ) + elif _cancelled: + eventManager().fire( + Events.SLICING_CANCELLED, + { + "slicer": slicer_name, + "stl": source_path, + "stl_location": source_location, + "gcode": dest_path, + "gcode_location": dest_location, + }, + ) + else: + source_meta = self.get_metadata(source_location, source_path) + hash = source_meta.get("hash", "n/a") + + import io + + links = [("model", {"name": source_path})] + _, stl_name = self.split_path(source_location, source_path) + file_obj = StreamWrapper( + os.path.basename(dest_path), + io.BytesIO( + ";Generated from {stl_name} (hash: {hash})\n".format( + **locals() + ).encode("ascii", "replace") + ), + io.FileIO(tmp_path, "rb"), + ) + + printer_profile = self._printer_profile_manager.get( + printer_profile_id + ) + self.add_file( + dest_location, + dest_path, + file_obj, + display=display, + links=links, + allow_overwrite=True, + printer_profile=printer_profile, + analysis=_analysis, + ) + + end_time = octoprint.util.monotonic_time() + eventManager().fire( + Events.SLICING_DONE, + { + "slicer": slicer_name, + "stl": source_path, + "stl_location": source_location, + "gcode": dest_path, + "gcode_location": dest_location, + "time": end_time - start_time, + }, + ) + + if callback is not None: + if callback_args is None: + callback_args = () + callback(*callback_args) + finally: + os.remove(tmp_path) + + source_job_key = (source_location, source_path) + dest_job_key = (dest_location, dest_path) + + with self._slicing_jobs_mutex: + if source_job_key in self._slicing_jobs: + del self._slicing_jobs[source_job_key] + if dest_job_key in self._slicing_jobs: + del self._slicing_jobs[dest_job_key] + + slicer = self._slicing_manager.get_slicer(slicer_name) + + start_time = octoprint.util.monotonic_time() + eventManager().fire( + Events.SLICING_STARTED, + { + "slicer": slicer_name, + "stl": source_path, + "stl_location": source_location, + "gcode": dest_path, + "gcode_location": dest_location, + "progressAvailable": slicer.get_slicer_properties().get( + "progress_report", False + ) + if slicer + else False, + }, + ) + + import tempfile + + f = tempfile.NamedTemporaryFile(suffix=".gco", delete=False) + temp_path = f.name + f.close() + + with self._slicing_jobs_mutex: + source_job_key = (source_location, source_path) + dest_job_key = (dest_location, dest_path) + if dest_job_key in self._slicing_jobs: + ( + job_slicer_name, + job_absolute_source_path, + job_temp_path, + ) = self._slicing_jobs[dest_job_key] + + self._slicing_manager.cancel_slicing( + job_slicer_name, job_absolute_source_path, job_temp_path + ) + del self._slicing_jobs[dest_job_key] + + self._slicing_jobs[dest_job_key] = self._slicing_jobs[source_job_key] = ( + slicer_name, + absolute_source_path, + temp_path, + ) + + args = ( + source_location, + source_path, + temp_path, + dest_location, + dest_path, + start_time, + printer_profile_id, + callback, + callback_args, + ) + self._slicing_manager.slice( + slicer_name, + absolute_source_path, + temp_path, + profile, + stlProcessed, + position=position, + callback_args=args, + overrides=overrides, + printer_profile_id=printer_profile_id, + on_progress=self.on_slicing_progress, + on_progress_args=( + slicer_name, + source_location, + source_path, + dest_location, + dest_path, + ), + ) + + def on_slicing_progress( + self, + slicer, + source_location, + source_path, + dest_location, + dest_path, + _progress=None, + ): + if not _progress: + return + + progress_int = int(_progress * 100) + if self._last_slicing_progress != progress_int: + self._last_slicing_progress = progress_int + for callback in self._slicing_progress_callbacks: + try: + callback.sendSlicingProgress( + slicer, + source_location, + source_path, + dest_location, + dest_path, + progress_int, + ) + except Exception: + self._logger.exception( + "Exception while pushing slicing progress", + extra={"callback": fqcn(callback)}, + ) + + if progress_int: + + def call_plugins( + slicer, + source_location, + source_path, + dest_location, + dest_path, + progress, + ): + for plugin in self._progress_plugins: + try: + plugin.on_slicing_progress( + slicer, + source_location, + source_path, + dest_location, + dest_path, + progress, + ) + except Exception: + self._logger.exception( + "Exception while sending slicing progress to plugin %s" + % plugin._identifier, + extra={"plugin": plugin._identifier}, + ) + + import threading + + thread = threading.Thread( + target=call_plugins, + args=( + slicer, + source_location, + source_path, + dest_location, + dest_path, + progress_int, + ), + ) + thread.daemon = False + thread.start() + + def get_busy_files(self): + return self._slicing_jobs.keys() + + def file_in_path(self, destination, path, file): + return self._storage(destination).file_in_path(path, file) + + def file_exists(self, destination, path): + return self._storage(destination).file_exists(path) + + def folder_exists(self, destination, path): + return self._storage(destination).folder_exists(path) + + def list_files( + self, + destinations=None, + path=None, + filter=None, + recursive=None, + level=0, + force_refresh=False, + ): + if not destinations: + destinations = list(self._storage_managers.keys()) + if isinstance(destinations, str): + destinations = [destinations] + + result = {} + for dst in destinations: + result[dst] = self._storage_managers[dst].list_files( + path=path, + filter=filter, + recursive=recursive, + level=level, + force_refresh=force_refresh, + ) + return result + + def add_file( + self, + destination, + path, + file_object, + links=None, + allow_overwrite=False, + printer_profile=None, + analysis=None, + display=None, + ): + if printer_profile is None: + printer_profile = self._printer_profile_manager.get_current_or_default() + + for name, hook in self._preprocessor_hooks.items(): + try: + hook_file_object = hook( + path, + file_object, + links=links, + printer_profile=printer_profile, + allow_overwrite=allow_overwrite, + ) + except Exception: + self._logger.exception( + "Error when calling preprocessor hook for plugin {}, ignoring".format( + name + ), + extra={"plugin": name}, + ) + continue + + if hook_file_object is not None: + file_object = hook_file_object + + queue_entry = self._analysis_queue_entry(destination, path) + + if queue_entry is not None: + self._analysis_queue.dequeue(queue_entry) + + path_in_storage = self._storage(destination).add_file( + path, + file_object, + links=links, + printer_profile=printer_profile, + allow_overwrite=allow_overwrite, + display=display, + ) + + queue_entry = self._analysis_queue_entry( + destination, + path_in_storage, + printer_profile=printer_profile, + analysis=analysis, + ) + if queue_entry: + self._analysis_queue.enqueue(queue_entry, high_priority=True) + + _, name = self._storage(destination).split_path(path_in_storage) + eventManager().fire( + Events.FILE_ADDED, + { + "storage": destination, + "path": path_in_storage, + "name": name, + "type": get_file_type(name), + }, + ) + eventManager().fire(Events.UPDATED_FILES, {"type": "printables"}) + return path_in_storage + + def remove_file(self, destination, path): + queue_entry = self._analysis_queue_entry(destination, path) + self._analysis_queue.dequeue(queue_entry) + self._storage(destination).remove_file(path) + + _, name = self._storage(destination).split_path(path) + eventManager().fire( + Events.FILE_REMOVED, + { + "storage": destination, + "path": path, + "name": name, + "type": get_file_type(name), + }, + ) + eventManager().fire(Events.UPDATED_FILES, {"type": "printables"}) + + def copy_file(self, destination, source, dst): + path_in_storage = self._storage(destination).copy_file(source, dst) + if not self.has_analysis(destination, path_in_storage): + queue_entry = self._analysis_queue_entry(destination, path_in_storage) + if queue_entry: + self._analysis_queue.enqueue(queue_entry) + + _, name = self._storage(destination).split_path(path_in_storage) + eventManager().fire( + Events.FILE_ADDED, + { + "storage": destination, + "path": path_in_storage, + "name": name, + "type": get_file_type(name), + }, + ) + eventManager().fire(Events.UPDATED_FILES, {"type": "printables"}) + + def move_file(self, destination, source, dst): + queue_entry = self._analysis_queue_entry(destination, source) + self._analysis_queue.dequeue(queue_entry) + path = self._storage(destination).move_file(source, dst) + if not self.has_analysis(destination, path): + queue_entry = self._analysis_queue_entry(destination, path) + if queue_entry: + self._analysis_queue.enqueue(queue_entry) + + source_path_in_storage = self._storage(destination).path_in_storage(source) + _, source_name = self._storage(destination).split_path(source_path_in_storage) + dst_path_in_storage = self._storage(destination).path_in_storage(dst) + _, dst_name = self._storage(destination).split_path(dst_path_in_storage) + + eventManager().fire( + Events.FILE_REMOVED, + { + "storage": destination, + "path": source_path_in_storage, + "name": source_name, + "type": get_file_type(source_name), + }, + ) + eventManager().fire( + Events.FILE_ADDED, + { + "storage": destination, + "path": dst_path_in_storage, + "name": dst_name, + "type": get_file_type(dst_name), + }, + ) + eventManager().fire(Events.UPDATED_FILES, {"type": "printables"}) + + def add_folder(self, destination, path, ignore_existing=True, display=None): + path_in_storage = self._storage(destination).add_folder( + path, ignore_existing=ignore_existing, display=display + ) + + _, name = self._storage(destination).split_path(path_in_storage) + eventManager().fire( + Events.FOLDER_ADDED, + {"storage": destination, "path": path_in_storage, "name": name}, + ) + eventManager().fire(Events.UPDATED_FILES, {"type": "printables"}) + return path_in_storage + + def remove_folder(self, destination, path, recursive=True): + self._analysis_queue.dequeue_folder(destination, path) + self._analysis_queue.pause() + self._storage(destination).remove_folder(path, recursive=recursive) + self._analysis_queue.resume() + + _, name = self._storage(destination).split_path(path) + eventManager().fire( + Events.FOLDER_REMOVED, {"storage": destination, "path": path, "name": name} + ) + eventManager().fire(Events.UPDATED_FILES, {"type": "printables"}) + + def copy_folder(self, destination, source, dst): + path_in_storage = self._storage(destination).copy_folder(source, dst) + self._determine_analysis_backlog( + destination, self._storage(destination), root=path_in_storage + ) + + _, name = self._storage(destination).split_path(path_in_storage) + eventManager().fire( + Events.FOLDER_ADDED, + {"storage": destination, "path": path_in_storage, "name": name}, + ) + eventManager().fire(Events.UPDATED_FILES, {"type": "printables"}) + + def move_folder(self, destination, source, dst): + self._analysis_queue.dequeue_folder(destination, source) + self._analysis_queue.pause() + dst_path_in_storage = self._storage(destination).move_folder(source, dst) + self._determine_analysis_backlog( + destination, self._storage(destination), root=dst_path_in_storage + ) + self._analysis_queue.resume() + + source_path_in_storage = self._storage(destination).path_in_storage(source) + _, source_name = self._storage(destination).split_path(source_path_in_storage) + _, dst_name = self._storage(destination).split_path(dst_path_in_storage) + + eventManager().fire( + Events.FOLDER_REMOVED, + {"storage": destination, "path": source_path_in_storage, "name": source_name}, + ) + eventManager().fire( + Events.FOLDER_ADDED, + {"storage": destination, "path": dst_path_in_storage, "name": dst_name}, + ) + eventManager().fire(Events.UPDATED_FILES, {"type": "printables"}) + + def has_analysis(self, destination, path): + return self._storage(destination).has_analysis(path) + + def get_metadata(self, destination, path): + return self._storage(destination).get_metadata(path) + + def add_link(self, destination, path, rel, data): + self._storage(destination).add_link(path, rel, data) + + def remove_link(self, destination, path, rel, data): + self._storage(destination).remove_link(path, rel, data) + + def log_print( + self, destination, path, timestamp, print_time, success, printer_profile + ): + try: + if success: + self._storage(destination).add_history( + path, + { + "timestamp": timestamp, + "printTime": print_time, + "success": success, + "printerProfile": printer_profile, + }, + ) + else: + self._storage(destination).add_history( + path, + { + "timestamp": timestamp, + "success": success, + "printerProfile": printer_profile, + }, + ) + eventManager().fire( + Events.METADATA_STATISTICS_UPDATED, {"storage": destination, "path": path} + ) + except NoSuchStorage: + # if there's no storage configured where to log the print, we'll just not log it + pass + + def save_recovery_data(self, origin, path, pos): + import time + + import yaml + + from octoprint.util import atomic_write + + data = { + "origin": origin, + "path": self.path_in_storage(origin, path), + "pos": pos, + "date": time.time(), + } + try: + with atomic_write(self._recovery_file, mode="wt", max_permissions=0o666) as f: + yaml.safe_dump( + data, stream=f, default_flow_style=False, indent=2, allow_unicode=True + ) + except Exception: + self._logger.exception( + "Could not write recovery data to file {}".format(self._recovery_file) + ) + + def delete_recovery_data(self): + if not os.path.isfile(self._recovery_file): + return + + try: + os.remove(self._recovery_file) + except Exception: + self._logger.exception( + "Error deleting recovery data file {}".format(self._recovery_file) + ) + + def get_recovery_data(self): + if not os.path.isfile(self._recovery_file): + return None + + import yaml + + try: + with io.open(self._recovery_file, "rt", encoding="utf-8") as f: + data = yaml.safe_load(f) + if not isinstance(data, dict) or not all( + map(lambda x: x in data, ("origin", "path", "pos", "date")) + ): + raise ValueError("Invalid recovery data structure") + return data + except Exception: + self._logger.exception( + "Could not read recovery data from file {}".format(self._recovery_file) + ) + self.delete_recovery_data() + + def get_additional_metadata(self, destination, path, key): + self._storage(destination).get_additional_metadata(path, key) + + def set_additional_metadata( + self, destination, path, key, data, overwrite=False, merge=False + ): + self._storage(destination).set_additional_metadata( + path, key, data, overwrite=overwrite, merge=merge + ) + + def remove_additional_metadata(self, destination, path, key): + self._storage(destination).remove_additional_metadata(path, key) + + def path_on_disk(self, destination, path): + return self._storage(destination).path_on_disk(path) + + def canonicalize(self, destination, path): + return self._storage(destination).canonicalize(path) + + def sanitize(self, destination, path): + return self._storage(destination).sanitize(path) + + def sanitize_name(self, destination, name): + return self._storage(destination).sanitize_name(name) + + def sanitize_path(self, destination, path): + return self._storage(destination).sanitize_path(path) + + def split_path(self, destination, path): + return self._storage(destination).split_path(path) + + def join_path(self, destination, *path): + return self._storage(destination).join_path(*path) + + def path_in_storage(self, destination, path): + return self._storage(destination).path_in_storage(path) + + def last_modified(self, destination, path=None, recursive=False): + return self._storage(destination).last_modified(path=path, recursive=recursive) + + def _storage(self, destination): + if destination not in self._storage_managers: + raise NoSuchStorage( + "No storage configured for destination {destination}".format(**locals()) + ) + return self._storage_managers[destination] + + def _add_analysis_result(self, destination, path, result): + if destination not in self._storage_managers: + return + if not result: + return + + storage_manager = self._storage_managers[destination] + storage_manager.set_additional_metadata(path, "analysis", result, overwrite=True) + + def _on_analysis_finished(self, entry, result): + self._add_analysis_result(entry.location, entry.path, result) + + def _analysis_queue_entry( + self, destination, path, printer_profile=None, analysis=None + ): + if printer_profile is None: + printer_profile = self._printer_profile_manager.get_current_or_default() + + absolute_path = self._storage(destination).path_on_disk(path) + _, file_name = self._storage(destination).split_path(path) + file_type = get_file_type(absolute_path) + + if file_type: + return QueueEntry( + file_name, + path, + file_type[-1], + destination, + absolute_path, + printer_profile, + analysis, + ) + else: + return None diff --git a/src/octoprint/filemanager/analysis.py b/src/octoprint/filemanager/analysis.py index 9c913d8151..dbc3f547ca 100644 --- a/src/octoprint/filemanager/analysis.py +++ b/src/octoprint/filemanager/analysis.py @@ -1,397 +1,533 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals __author__ = "Gina Häußge " -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" - +import copy import logging + try: - import queue + import queue except ImportError: - import Queue as queue + import Queue as queue + +import collections import os import threading -import collections import time from octoprint.events import Events, eventManager from octoprint.settings import settings - - -class QueueEntry(collections.namedtuple("QueueEntry", "name, path, type, location, absolute_path, printer_profile")): - """ - A :class:`QueueEntry` for processing through the :class:`AnalysisQueue`. Wraps the entry's properties necessary - for processing. - - Arguments: - name (str): Name of the file to analyze. - path (str): Storage location specific path to the file to analyze. - type (str): Type of file to analyze, necessary to map to the correct :class:`AbstractAnalysisQueue` sub class. - At the moment, only ``gcode`` is supported here. - location (str): Location the file is located on. - absolute_path (str): Absolute path on disk through which to access the file. - printer_profile (PrinterProfile): :class:`PrinterProfile` which to use for analysis. - """ - - def __str__(self): - return "{location}:{path}".format(location=self.location, path=self.path) +from octoprint.util import dict_merge +from octoprint.util import get_fully_qualified_classname as fqcn +from octoprint.util import monotonic_time +from octoprint.util.platform import CLOSE_FDS + +EMPTY_RESULT = { + "_empty": True, + "printingArea": { + "minX": 0, + "maxX": 0, + "minY": 0, + "maxY": 0, + "minZ": 0, + "maxZ": 0, + }, + "dimensions": {"width": 0, "height": 0, "depth": 0}, + "filament": {}, +} + + +class QueueEntry( + collections.namedtuple( + "QueueEntry", + "name, path, type, location, absolute_path, printer_profile, analysis", + ) +): + """ + A :class:`QueueEntry` for processing through the :class:`AnalysisQueue`. Wraps the entry's properties necessary + for processing. + + Arguments: + name (str): Name of the file to analyze. + path (str): Storage location specific path to the file to analyze. + type (str): Type of file to analyze, necessary to map to the correct :class:`AbstractAnalysisQueue` sub class. + At the moment, only ``gcode`` is supported here. + location (str): Location the file is located on. + absolute_path (str): Absolute path on disk through which to access the file. + printer_profile (PrinterProfile): :class:`PrinterProfile` which to use for analysis. + analysis (dict): :class:`GcodeAnalysisQueue` results from prior analysis, or ``None`` if there is none. + """ + + def __str__(self): + return "{location}:{path}".format(location=self.location, path=self.path) class AnalysisAborted(Exception): - def __init__(self, reenqueue=True, *args, **kwargs): - Exception.__init__(self, *args, **kwargs) - self.reenqueue = reenqueue + def __init__(self, reenqueue=True, *args, **kwargs): + Exception.__init__(self, *args, **kwargs) + self.reenqueue = reenqueue class AnalysisQueue(object): - """ - OctoPrint's :class:`AnalysisQueue` can manage various :class:`AbstractAnalysisQueue` implementations, mapped - by their machine code type. - - At the moment, only the analysis of GCODE files for 3D printing is supported, through :class:`GcodeAnalysisQueue`. - - By invoking :meth:`register_finish_callback` it is possible to register oneself as a callback to be invoked each - time the analysis of a queue entry finishes. The call parameters will be the finished queue entry as the first - and the analysis result as the second parameter. It is also possible to remove the registration again by invoking - :meth:`unregister_finish_callback`. - - :meth:`enqueue` allows enqueuing :class:`QueueEntry` instances to analyze. If the :attr:`QueueEntry.type` is unknown - (no specific child class of :class:`AbstractAnalysisQueue` is registered for it), nothing will happen. Otherwise the - entry will be enqueued with the type specific analysis queue. - """ - - def __init__(self): - self._logger = logging.getLogger(__name__) - self._callbacks = [] - self._queues = dict( - gcode=GcodeAnalysisQueue(self._analysis_finished) - ) - - def register_finish_callback(self, callback): - self._callbacks.append(callback) - - def unregister_finish_callback(self, callback): - self._callbacks.remove(callback) - - def enqueue(self, entry, high_priority=False): - if not entry.type in self._queues: - return False - - self._queues[entry.type].enqueue(entry, high_priority=high_priority) - return True - - def dequeue(self, entry): - if not entry.type in self._queues: - return False - - self._queues[entry.type].dequeue(entry.location, entry.path) - - def dequeue_folder(self, destination, path): - for queue in self._queues.values(): - queue.dequeue_folder(destination, path) - - def pause(self): - for queue in self._queues.values(): - queue.pause() - - def resume(self): - for queue in self._queues.values(): - queue.resume() - - def _analysis_finished(self, entry, result): - for callback in self._callbacks: - callback(entry, result) - eventManager().fire(Events.METADATA_ANALYSIS_FINISHED, {"name": entry.name, - "path": entry.path, - "origin": entry.location, - "result": result, + """ + OctoPrint's :class:`AnalysisQueue` can manage various :class:`AbstractAnalysisQueue` implementations, mapped + by their machine code type. + + By invoking :meth:`register_finish_callback` it is possible to register oneself as a callback to be invoked each + time the analysis of a queue entry finishes. The call parameters will be the finished queue entry as the first + and the analysis result as the second parameter. It is also possible to remove the registration again by invoking + :meth:`unregister_finish_callback`. + + :meth:`enqueue` allows enqueuing :class:`QueueEntry` instances to analyze. If the :attr:`QueueEntry.type` is unknown + (no specific child class of :class:`AbstractAnalysisQueue` is registered for it), nothing will happen. Otherwise the + entry will be enqueued with the type specific analysis queue. + """ + + def __init__(self, queue_factories): + self._logger = logging.getLogger(__name__) + self._callbacks = [] + + self._queues = {} + for key, queue_factory in queue_factories.items(): + self._queues[key] = queue_factory(self._analysis_finished) + + def register_finish_callback(self, callback): + self._callbacks.append(callback) + + def unregister_finish_callback(self, callback): + self._callbacks.remove(callback) + + def enqueue(self, entry, high_priority=False): + if entry is None: + return False + + if entry.type not in self._queues: + return False + + self._queues[entry.type].enqueue(entry, high_priority=high_priority) + return True + + def dequeue(self, entry): + if entry is None: + return False + + if entry.type not in self._queues: + return False + + self._queues[entry.type].dequeue(entry.location, entry.path) + + def dequeue_folder(self, destination, path): + for q in self._queues.values(): + q.dequeue_folder(destination, path) + + def pause(self): + for q in self._queues.values(): + q.pause() + + def resume(self): + for q in self._queues.values(): + q.resume() + + def _analysis_finished(self, entry, result): + for callback in self._callbacks: + try: + callback(entry, result) + except Exception: + self._logger.exception( + "Error while pushing analysis data to callback {}".format(callback), + extra={"callback": fqcn(callback)}, + ) + eventManager().fire( + Events.METADATA_ANALYSIS_FINISHED, + { + "name": entry.name, + "path": entry.path, + "origin": entry.location, + "result": result, + }, + ) - # TODO: deprecated, remove in a future release - "file": entry.path}) class AbstractAnalysisQueue(object): - """ - The :class:`AbstractAnalysisQueue` is the parent class of all specific analysis queues such as the - :class:`GcodeAnalysisQueue`. It offers methods to enqueue new entries to analyze and pausing and resuming analysis - processing. - - Arguments: - finished_callback (callable): Callback that will be called upon finishing analysis of an entry in the queue. - The callback will be called with the analyzed entry as the first argument and the analysis result as - returned from the queue implementation as the second parameter. - - .. automethod:: _do_analysis - - .. automethod:: _do_abort - """ - - LOW_PRIO = 100 - LOW_PRIO_ABORTED = 75 - HIGH_PRIO = 50 - HIGH_PRIO_ABORTED = 0 - - def __init__(self, finished_callback): - self._logger = logging.getLogger(__name__) - - self._finished_callback = finished_callback - - self._active = threading.Event() - self._active.set() - - self._done = threading.Event() - self._done.clear() - - self._currentFile = None - self._currentProgress = None - - self._queue = queue.PriorityQueue() - self._current = None - self._current_highprio = False - - self._worker = threading.Thread(target=self._work) - self._worker.daemon = True - self._worker.start() - - def enqueue(self, entry, high_priority=False): - """ - Enqueues an ``entry`` for analysis by the queue. - - If ``high_priority`` is True (defaults to False), the entry will be prioritized and hence processed before - other entries in the queue with normal priority. - - Arguments: - entry (QueueEntry): The :class:`QueueEntry` to analyze. - high_priority (boolean): Whether to process the provided entry with high priority (True) or not - (False, default) - """ - - if high_priority: - self._logger.debug("Adding entry {entry} to analysis queue with high priority".format(entry=entry)) - prio = self.__class__.HIGH_PRIO - else: - self._logger.debug("Adding entry {entry} to analysis queue with low priority".format(entry=entry)) - prio = self.__class__.LOW_PRIO - - self._queue.put((prio, entry, high_priority)) - if high_priority and self._current is not None and not self._current_highprio: - self._logger.debug("Aborting current analysis in favor of high priority one") - self._do_abort() - - def dequeue(self, location, path): - if self._current is not None and self._current.location == location \ - and self._current.path == path: - self._do_abort(reenqueue=False) - self._done.wait() - self._done.clear() - - def dequeue_folder(self, location, path): - if self._current is not None and self._current.location == location \ - and self._current.path.startswith(path + "/"): - self._do_abort(reenqueue=False) - self._done.wait() - self._done.clear() - - def pause(self): - """ - Pauses processing of the queue, e.g. when a print is active. - """ - - self._logger.debug("Pausing analysis") - self._active.clear() - if self._current is not None: - self._logger.debug("Aborting running analysis, will restart when analyzer is resumed") - self._do_abort() - - def resume(self): - """ - Resumes processing of the queue, e.g. when a print has finished. - """ - - self._logger.debug("Resuming analyzer") - self._active.set() - - def _work(self): - while True: - (priority, entry, high_priority) = self._queue.get() - self._logger.debug("Processing entry {} from queue (priority {})".format(entry, priority)) - self._active.wait() - - try: - self._analyze(entry, high_priority=high_priority) - self._queue.task_done() - self._done.set() - except AnalysisAborted as ex: - if ex.reenqueue: - self._queue.put((self.__class__.HIGH_PRIO_ABORTED if high_priority else self.__class__.LOW_PRIO_ABORTED, - entry, - high_priority)) - self._logger.debug("Running analysis of entry {} aborted".format(entry)) - self._queue.task_done() - self._done.set() - else: - time.sleep(1.0) - - def _analyze(self, entry, high_priority=False): - path = entry.absolute_path - if path is None or not os.path.exists(path): - return - - self._current = entry - self._current_highprio = high_priority - self._current_progress = 0 - - try: - start_time = time.time() - self._logger.info("Starting analysis of {}".format(entry)) - eventManager().fire(Events.METADATA_ANALYSIS_STARTED, {"name": entry.name, - "path": entry.path, - "origin": entry.location, - "type": entry.type, - - # TODO deprecated, remove in 1.4.0 - "file": entry.path}) - try: - result = self._do_analysis(high_priority=high_priority) - except TypeError: - result = self._do_analysis() - self._logger.info("Analysis of entry {} finished, needed {:.2f}s".format(entry, time.time() - start_time)) - self._finished_callback(self._current, result) - finally: - self._current = None - self._current_progress = None - - def _do_analysis(self, high_priority=False): - """ - Performs the actual analysis of the current entry which can be accessed via ``self._current``. Needs to be - overridden by sub classes. - - Arguments: - high_priority (bool): Whether the current entry has high priority or not. - - Returns: - object: The result of the analysis which will be forwarded to the ``finished_callback`` provided during - construction. - """ - return None - - def _do_abort(self, reenqueue=True): - """ - Aborts analysis of the current entry. Needs to be overridden by sub classes. - """ - pass + """ + The :class:`AbstractAnalysisQueue` is the parent class of all specific analysis queues such as the + :class:`GcodeAnalysisQueue`. It offers methods to enqueue new entries to analyze and pausing and resuming analysis + processing. + + Arguments: + finished_callback (callable): Callback that will be called upon finishing analysis of an entry in the queue. + The callback will be called with the analyzed entry as the first argument and the analysis result as + returned from the queue implementation as the second parameter. + + .. automethod:: _do_analysis + + .. automethod:: _do_abort + """ + + LOW_PRIO = 100 + LOW_PRIO_ABORTED = 75 + HIGH_PRIO = 50 + HIGH_PRIO_ABORTED = 0 + + def __init__(self, finished_callback): + self._logger = logging.getLogger(__name__) + + self._finished_callback = finished_callback + + self._active = threading.Event() + self._active.set() + + self._done = threading.Event() + self._done.clear() + + self._currentFile = None + self._currentProgress = None + + self._queue = queue.PriorityQueue() + self._current = None + self._current_highprio = False + + self._worker = threading.Thread(target=self._work) + self._worker.daemon = True + self._worker.start() + + def enqueue(self, entry, high_priority=False): + """ + Enqueues an ``entry`` for analysis by the queue. + + If ``high_priority`` is True (defaults to False), the entry will be prioritized and hence processed before + other entries in the queue with normal priority. + + Arguments: + entry (QueueEntry): The :class:`QueueEntry` to analyze. + high_priority (boolean): Whether to process the provided entry with high priority (True) or not + (False, default) + """ + + if settings().get(["gcodeAnalysis", "runAt"]) == "never": + self._logger.debug( + "Ignoring entry {entry} for analysis queue".format(entry=entry) + ) + return + elif high_priority: + self._logger.debug( + "Adding entry {entry} to analysis queue with high priority".format( + entry=entry + ) + ) + prio = self.__class__.HIGH_PRIO + else: + self._logger.debug( + "Adding entry {entry} to analysis queue with low priority".format( + entry=entry + ) + ) + prio = self.__class__.LOW_PRIO + + self._queue.put((prio, entry, high_priority)) + if high_priority and self._current is not None and not self._current_highprio: + self._logger.debug("Aborting current analysis in favor of high priority one") + self._do_abort() + + def dequeue(self, location, path): + if ( + self._current is not None + and self._current.location == location + and self._current.path == path + ): + self._do_abort(reenqueue=False) + self._done.wait() + self._done.clear() + + def dequeue_folder(self, location, path): + if ( + self._current is not None + and self._current.location == location + and self._current.path.startswith(path + "/") + ): + self._do_abort(reenqueue=False) + self._done.wait() + self._done.clear() + + def pause(self): + """ + Pauses processing of the queue, e.g. when a print is active. + """ + + self._logger.debug("Pausing analysis") + self._active.clear() + if self._current is not None: + self._logger.debug( + "Aborting running analysis, will restart when analyzer is resumed" + ) + self._do_abort() + + def resume(self): + """ + Resumes processing of the queue, e.g. when a print has finished. + """ + + self._logger.debug("Resuming analyzer") + self._active.set() + + def _work(self): + while True: + (priority, entry, high_priority) = self._queue.get() + self._logger.debug( + "Processing entry {} from queue (priority {})".format(entry, priority) + ) + self._active.wait() + + try: + self._analyze(entry, high_priority=high_priority) + self._queue.task_done() + self._done.set() + except AnalysisAborted as ex: + if ex.reenqueue: + self._queue.put( + ( + self.__class__.HIGH_PRIO_ABORTED + if high_priority + else self.__class__.LOW_PRIO_ABORTED, + entry, + high_priority, + ) + ) + self._logger.debug("Running analysis of entry {} aborted".format(entry)) + self._queue.task_done() + self._done.set() + else: + time.sleep(1.0) + + def _analyze(self, entry, high_priority=False): + path = entry.absolute_path + if path is None or not os.path.exists(path): + return + + self._current = entry + self._current_highprio = high_priority + self._current_progress = 0 + + try: + start_time = monotonic_time() + self._logger.info("Starting analysis of {}".format(entry)) + eventManager().fire( + Events.METADATA_ANALYSIS_STARTED, + { + "name": entry.name, + "path": entry.path, + "origin": entry.location, + "type": entry.type, + }, + ) + try: + result = self._do_analysis(high_priority=high_priority) + except TypeError: + result = self._do_analysis() + self._logger.info( + "Analysis of entry {} finished, needed {:.2f}s".format( + entry, monotonic_time() - start_time + ) + ) + self._finished_callback(self._current, result) + except RuntimeError as exc: + self._logger.error( + "Analysis for {} ran into error: {}".format(self._current, exc) + ) + finally: + self._current = None + self._current_progress = None + + def _do_analysis(self, high_priority=False): + """ + Performs the actual analysis of the current entry which can be accessed via ``self._current``. Needs to be + overridden by sub classes. + + Arguments: + high_priority (bool): Whether the current entry has high priority or not. + + Returns: + object: The result of the analysis which will be forwarded to the ``finished_callback`` provided during + construction. + """ + return None + + def _do_abort(self, reenqueue=True): + """ + Aborts analysis of the current entry. Needs to be overridden by sub classes. + """ + pass class GcodeAnalysisQueue(AbstractAnalysisQueue): - """ - A queue to analyze GCODE files. Analysis results are :class:`dict` instances structured as follows: - - .. list-table:: - :widths: 25 70 - - - * **Key** - * **Description** - - * ``estimatedPrintTime`` - * Estimated time the file take to print, in minutes - - * ``filament`` - * Substructure describing estimated filament usage. Keys are ``tool0`` for the first extruder, ``tool1`` for - the second and so on. For each tool extruded length and volume (based on diameter) are provided. - - * ``filament.toolX.length`` - * The extruded length in mm - - * ``filament.toolX.volume`` - * The extruded volume in cm³ - """ - - def __init__(self, finished_callback): - AbstractAnalysisQueue.__init__(self, finished_callback) - - self._aborted = False - self._reenqueue = False - - def _do_analysis(self, high_priority=False): - import sarge - import sys - import yaml - - try: - throttle = settings().getFloat(["gcodeAnalysis", "throttle_highprio"]) if high_priority \ - else settings().getFloat(["gcodeAnalysis", "throttle_normalprio"]) - throttle_lines = settings().getInt(["gcodeAnalysis", "throttle_lines"]) - max_extruders = settings().getInt(["gcodeAnalysis", "maxExtruders"]) - g90_extruder = settings().getBoolean(["feature", "g90InfluencesExtruder"]) - speedx = self._current.printer_profile["axes"]["x"]["speed"] - speedy = self._current.printer_profile["axes"]["y"]["speed"] - offsets = self._current.printer_profile["extruder"]["offsets"] - - command = [sys.executable, "-m", "octoprint", "analysis", "gcode", - "--speed-x={}".format(speedx), "--speed-y={}".format(speedy), - "--max-t={}".format(max_extruders), "--throttle={}".format(throttle), - "--throttle-lines={}".format(throttle_lines)] - for offset in offsets[1:]: - command += ["--offset", str(offset[0]), str(offset[1])] - if g90_extruder: - command += ["--g90-extruder"] - command.append(self._current.absolute_path) - - self._logger.info("Invoking analysis command: {}".format(" ".join(command))) - - self._aborted = False - p = sarge.run(command, async=True, stdout=sarge.Capture()) - - while len(p.commands) == 0: - # somewhat ugly... we can't use wait_events because - # the events might not be all set if an exception - # by sarge is triggered within the async process - # thread - time.sleep(0.01) - - # by now we should have a command, let's wait for its - # process to have been prepared - p.commands[0].process_ready.wait() - - if not p.commands[0].process: - # the process might have been set to None in case of any exception - raise RuntimeError(u"Error while trying to run command {}".format(" ".join(command))) - - try: - # let's wait for stuff to finish - while p.returncode is None: - if self._aborted: - # oh, we shall abort, let's do so! - p.commands[0].terminate() - raise AnalysisAborted(reenqueue=self._reenqueue) - - # else continue - p.commands[0].poll() - finally: - p.close() - - output = p.stdout.text - self._logger.debug("Got output: {!r}".format(output)) - - if not "RESULTS:" in output: - raise RuntimeError("No analysis result found") - - _, output = output.split("RESULTS:") - analysis = yaml.safe_load(output) - - result = dict() - result["printingArea"] = analysis["printing_area"] - result["dimensions"] = analysis["dimensions"] - if analysis["total_time"]: - result["estimatedPrintTime"] = analysis["total_time"] * 60 - if analysis["extrusion_length"]: - result["filament"] = dict() - for i in range(len(analysis["extrusion_length"])): - result["filament"]["tool%d" % i] = { - "length": analysis["extrusion_length"][i], - "volume": analysis["extrusion_volume"][i] - } - return result - finally: - self._gcode = None - - def _do_abort(self, reenqueue=True): - self._aborted = True - self._reenqueue = reenqueue + """ + A queue to analyze GCODE files. Analysis results are :class:`dict` instances structured as follows: + + .. list-table:: + :widths: 25 70 + + - * **Key** + * **Description** + - * ``estimatedPrintTime`` + * Estimated time the file take to print, in seconds + - * ``filament`` + * Substructure describing estimated filament usage. Keys are ``tool0`` for the first extruder, ``tool1`` for + the second and so on. For each tool extruded length and volume (based on diameter) are provided. + - * ``filament.toolX.length`` + * The extruded length in mm + - * ``filament.toolX.volume`` + * The extruded volume in cm³ + - * ``printingArea`` + * Bounding box of the printed object in the print volume (minimum and maximum coordinates) + - * ``printingArea.minX`` + * Minimum X coordinate of the printed object + - * ``printingArea.maxX`` + * Maximum X coordinate of the printed object + - * ``printingArea.minY`` + * Minimum Y coordinate of the printed object + - * ``printingArea.maxY`` + * Maximum Y coordinate of the printed object + - * ``printingArea.minZ`` + * Minimum Z coordinate of the printed object + - * ``printingArea.maxZ`` + * Maximum Z coordinate of the printed object + - * ``dimensions`` + * Dimensions of the printed object in X, Y, Z + - * ``dimensions.width`` + * Width of the printed model along the X axis, in mm + - * ``dimensions.depth`` + * Depth of the printed model along the Y axis, in mm + - * ``dimensions.height`` + * Height of the printed model along the Z axis, in mm + """ + + def __init__(self, finished_callback): + AbstractAnalysisQueue.__init__(self, finished_callback) + + self._aborted = False + self._reenqueue = False + + def _do_analysis(self, high_priority=False): + import sys + + import sarge + import yaml + + if self._current.analysis and all( + map( + lambda x: x in self._current.analysis, + ("printingArea", "dimensions", "estimatedPrintTime", "filament"), + ) + ): + return self._current.analysis + + try: + throttle = ( + settings().getFloat(["gcodeAnalysis", "throttle_highprio"]) + if high_priority + else settings().getFloat(["gcodeAnalysis", "throttle_normalprio"]) + ) + throttle_lines = settings().getInt(["gcodeAnalysis", "throttle_lines"]) + max_extruders = settings().getInt(["gcodeAnalysis", "maxExtruders"]) + g90_extruder = settings().getBoolean(["feature", "g90InfluencesExtruder"]) + bed_z = settings().getFloat(["gcodeAnalysis", "bedZ"]) + speedx = self._current.printer_profile["axes"]["x"]["speed"] + speedy = self._current.printer_profile["axes"]["y"]["speed"] + offsets = self._current.printer_profile["extruder"]["offsets"] + + command = [ + sys.executable, + "-m", + "octoprint", + "analysis", + "gcode", + "--speed-x={}".format(speedx), + "--speed-y={}".format(speedy), + "--max-t={}".format(max_extruders), + "--throttle={}".format(throttle), + "--throttle-lines={}".format(throttle_lines), + "--bed-z={}".format(bed_z), + ] + for offset in offsets[1:]: + command += ["--offset", str(offset[0]), str(offset[1])] + if g90_extruder: + command += ["--g90-extruder"] + command.append(self._current.absolute_path) + + self._logger.info("Invoking analysis command: {}".format(" ".join(command))) + + self._aborted = False + p = sarge.run( + command, close_fds=CLOSE_FDS, async_=True, stdout=sarge.Capture() + ) + + while len(p.commands) == 0: + # somewhat ugly... we can't use wait_events because + # the events might not be all set if an exception + # by sarge is triggered within the async process + # thread + time.sleep(0.01) + + # by now we should have a command, let's wait for its + # process to have been prepared + p.commands[0].process_ready.wait() + + if not p.commands[0].process: + # the process might have been set to None in case of any exception + raise RuntimeError( + "Error while trying to run command {}".format(" ".join(command)) + ) + + try: + # let's wait for stuff to finish + while p.returncode is None: + if self._aborted: + # oh, we shall abort, let's do so! + p.commands[0].terminate() + raise AnalysisAborted(reenqueue=self._reenqueue) + + # else continue + p.commands[0].poll() + finally: + p.close() + + output = p.stdout.text + self._logger.debug("Got output: {!r}".format(output)) + + result = {} + if "ERROR:" in output: + _, error = output.split("ERROR:") + raise RuntimeError(error.strip()) + elif "EMPTY:" in output: + self._logger.info("Result is empty, no extrusions found") + result = copy.deepcopy(EMPTY_RESULT) + elif "RESULTS:" not in output: + raise RuntimeError("No analysis result found") + else: + _, output = output.split("RESULTS:") + analysis = yaml.safe_load(output) + + result["printingArea"] = analysis["printing_area"] + result["dimensions"] = analysis["dimensions"] + if analysis["total_time"]: + result["estimatedPrintTime"] = analysis["total_time"] * 60 + if analysis["extrusion_length"]: + result["filament"] = {} + for i in range(len(analysis["extrusion_length"])): + result["filament"]["tool%d" % i] = { + "length": analysis["extrusion_length"][i], + "volume": analysis["extrusion_volume"][i], + } + + if self._current.analysis and isinstance(self._current.analysis, dict): + return dict_merge(result, self._current.analysis) + else: + return result + finally: + self._gcode = None + + def _do_abort(self, reenqueue=True): + self._aborted = True + self._reenqueue = reenqueue diff --git a/src/octoprint/filemanager/destinations.py b/src/octoprint/filemanager/destinations.py index 11a971884c..1400d50c32 100644 --- a/src/octoprint/filemanager/destinations.py +++ b/src/octoprint/filemanager/destinations.py @@ -1,8 +1,9 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +from __future__ import absolute_import, division, print_function, unicode_literals + +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" + class FileDestinations(object): - SDCARD = "sdcard" - LOCAL = "local" + SDCARD = "sdcard" + LOCAL = "local" diff --git a/src/octoprint/filemanager/storage.py b/src/octoprint/filemanager/storage.py index e4d9d9068b..f765fb427c 100644 --- a/src/octoprint/filemanager/storage.py +++ b/src/octoprint/filemanager/storage.py @@ -1,1565 +1,1993 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals __author__ = "Gina Häußge " -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" - +import copy +import io import logging import os -import pylru import shutil -import re + +import pylru try: - from os import scandir, walk + from os import scandir, walk except ImportError: - from scandir import scandir, walk + from scandir import scandir, walk -from octoprint.util import atomic_write from contextlib import contextmanager -from copy import deepcopy - -from past.builtins import basestring -from emoji import demojize -from slugify import Slugify import octoprint.filemanager +from octoprint.util import atomic_write, is_hidden_path, time_this, to_bytes, to_unicode +from octoprint.util.files import sanitize_filename -from octoprint.util import is_hidden_path, to_unicode class StorageInterface(object): - """ - Interface of storage adapters for OctoPrint. - """ - - @property - def analysis_backlog(self): - """ - Get an iterator over all items stored in the storage that need to be analysed by the :class:`~octoprint.filemanager.AnalysisQueue`. - - The yielded elements are expected as storage specific absolute paths to the respective files. Don't forget - to recurse into folders if your storage adapter supports those. - - :return: an iterator yielding all un-analysed files in the storage - """ - # empty generator pattern, yield is intentionally unreachable - return - yield - - def analysis_backlog_for_path(self, path=None): - # empty generator pattern, yield is intentionally unreachable - return - yield - - def last_modified(self, path=None, recursive=False): - """ - Get the last modification date of the specified ``path`` or ``path``'s subtree. - - Args: - path (str or None): Path for which to determine the subtree's last modification date. If left out or - set to None, defatuls to storage root. - recursive (bool): Whether to determine only the date of the specified ``path`` (False, default) or - the whole ``path``'s subtree (True). - - Returns: (float) The last modification date of the indicated subtree - """ - raise NotImplementedError() - - def file_in_path(self, path, filepath): - """ - Returns whether the file indicated by ``file`` is inside ``path`` or not. - :param string path: the path to check - :param string filepath: path to the file - :return: ``True`` if the file is inside the path, ``False`` otherwise - """ - return NotImplementedError() - - def file_exists(self, path): - """ - Returns whether the file indicated by ``path`` exists or not. - :param string path: the path to check for existence - :return: ``True`` if the file exists, ``False`` otherwise - """ - raise NotImplementedError() - - def folder_exists(self, path): - """ - Returns whether the folder indicated by ``path`` exists or not. - :param string path: the path to check for existence - :return: ``True`` if the folder exists, ``False`` otherwise - """ - raise NotImplementedError() - - def list_files(self, path=None, filter=None, recursive=True): - """ - List all files in storage starting at ``path``. If ``recursive`` is set to True (the default), also dives into - subfolders. - - An optional filter function can be supplied which will be called with a file name and file data and which has - to return True if the file is to be included in the result or False if not. - - The data structure of the returned result will be a dictionary mapping from file names to entry data. File nodes - will contain their metadata here, folder nodes will contain their contained files and folders. Example:: - - { - "some_folder": { - "name": "some_folder", - "path": "some_folder", - "type": "folder", - "children": { - "some_sub_folder": { - "name": "some_sub_folder", - "path": "some_folder/some_sub_folder", - "type": "folder", - "typePath": ["folder"], - "children": { ... } - }, - "some_file.gcode": { - "name": "some_file.gcode", - "path": "some_folder/some_file.gcode", - "type": "machinecode", - "typePath": ["machinecode", "gcode"], - "hash": "", - "links": [ ... ], - ... - }, - ... - } - "test.gcode": { - "name": "test.gcode", - "path": "test.gcode", - "type": "machinecode", - "typePath": ["machinecode", "gcode"], - "hash": "", - "links": [...], - ... - }, - "test.stl": { - "name": "test.stl", - "path": "test.stl", - "type": "model", - "typePath": ["model", "stl"], - "hash": "", - "links": [...], - ... - }, - ... - } - - :param string path: base path from which to recursively list all files, optional, if not supplied listing will start - from root of base folder - :param function filter: a filter that matches the files that are to be returned, may be left out in which case no - filtering will take place - :param bool recursive: will also step into sub folders for building the complete list if set to True - :return: a dictionary mapping entry names to entry data that represents the whole file list - """ - raise NotImplementedError() - - def add_folder(self, path, ignore_existing=True, display=None): - """ - Adds a folder as ``path`` - - The ``path`` will be sanitized. - - :param string path: the path of the new folder - :param bool ignore_existing: if set to True, no error will be raised if the folder to be added already exists - :param unicode display: display name of the folder - :return: the sanitized name of the new folder to be used for future references to the folder - """ - raise NotImplementedError() - - def remove_folder(self, path, recursive=True): - """ - Removes the folder at ``path`` - - :param string path: the path of the folder to remove - :param bool recursive: if set to True, contained folders and files will also be removed, otherwise and error will - be raised if the folder is not empty (apart from ``.metadata.yaml``) when it's to be removed - """ - raise NotImplementedError() - - def copy_folder(self, source, destination): - """ - Copys the folder ``source`` to ``destination`` - - :param string source: path to the source folder - :param string destination: path to destination - - :return: the path in the storage to the copy of the folder - """ - raise NotImplementedError() - - def move_folder(self, source, destination): - """ - Moves the folder ``source`` to ``destination`` - - :param string source: path to the source folder - :param string destination: path to destination - - :return: the new path in the storage to the folder - """ - raise NotImplementedError() - - def add_file(self, path, file_object, printer_profile=None, links=None, allow_overwrite=False, display=None): - """ - Adds the file ``file_object`` as ``path`` - - :param string path: the file's new path, will be sanitized - :param object file_object: a file object that provides a ``save`` method which will be called with the destination path - where the object should then store its contents - :param object printer_profile: the printer profile associated with this file (if any) - :param list links: any links to add with the file - :param bool allow_overwrite: if set to True no error will be raised if the file already exists and the existing file - and its metadata will just be silently overwritten - :param unicode display: display name of the file - :return: the sanitized name of the file to be used for future references to it - """ - raise NotImplementedError() - - def remove_file(self, path): - """ - Removes the file at ``path`` - - Will also take care of deleting the corresponding entries - in the metadata and deleting all links pointing to the file. - - :param string path: path of the file to remove - """ - raise NotImplementedError() - - def copy_file(self, source, destination): - """ - Copys the file ``source`` to ``destination`` - - :param string source: path to the source file - :param string destination: path to destination - - :return: the path in the storage to the copy of the file - """ - raise NotImplementedError() - - def move_file(self, source, destination): - """ - Moves the file ``source`` to ``destination`` - - :param string source: path to the source file - :param string destination: path to destination - - :return: the new path in the storage to the file - """ - raise NotImplementedError() - - def has_analysis(self, path): - """ - Returns whether the file at path has been analysed yet - - :param path: virtual path to the file for which to retrieve the metadata - """ - raise NotImplementedError() - - def get_metadata(self, path): - """ - Retrieves the metadata for the file ``path``. - - :param path: virtual path to the file for which to retrieve the metadata - :return: the metadata associated with the file - """ - raise NotImplementedError() - - def add_link(self, path, rel, data): - """ - Adds a link of relation ``rel`` to file ``path`` with the given ``data``. - - The following relation types are currently supported: - - * ``model``: adds a link to a model from which the file was created/sliced, expected additional data is the ``name`` - and optionally the ``hash`` of the file to link to. If the link can be resolved against another file on the - current ``path``, not only will it be added to the links of ``name`` but a reverse link of type ``machinecode`` - referring to ``name`` and its hash will also be added to the linked ``model`` file - * ``machinecode``: adds a link to a file containing machine code created from the current file (model), expected - additional data is the ``name`` and optionally the ``hash`` of the file to link to. If the link can be resolved - against another file on the current ``path``, not only will it be added to the links of ``name`` but a reverse - link of type ``model`` referring to ``name`` and its hash will also be added to the linked ``model`` file. - * ``web``: adds a location on the web associated with this file (e.g. a website where to download a model), - expected additional data is a ``href`` attribute holding the website's URL and optionally a ``retrieved`` - attribute describing when the content was retrieved - - Note that adding ``model`` links to files identifying as models or ``machinecode`` links to files identifying - as machine code will be refused. - - :param path: path of the file for which to add a link - :param rel: type of relation of the link to add (currently ``model``, ``machinecode`` and ``web`` are supported) - :param data: additional data of the link to add - """ - raise NotImplementedError() - - def remove_link(self, path, rel, data): - """ - Removes the link consisting of ``rel`` and ``data`` from file ``name`` on ``path``. - - :param path: path of the file from which to remove the link - :param rel: type of relation of the link to remove (currently ``model``, ``machinecode`` and ``web`` are supported) - :param data: additional data of the link to remove, must match existing link - """ - raise NotImplementedError() - - def set_additional_metadata(self, path, key, data, overwrite=False, merge=False): - """ - Adds additional metadata to the metadata of ``path``. Metadata in ``data`` will be saved under ``key``. - - If ``overwrite`` is set and ``key`` already exists in ``name``'s metadata, the current value will be overwritten. - - If ``merge`` is set and ``key`` already exists and both ``data`` and the existing data under ``key`` are dictionaries, - the two dictionaries will be merged recursively. - - :param path: the virtual path to the file for which to add additional metadata - :param key: key of metadata to add - :param data: metadata to add - :param overwrite: if True and ``key`` already exists, it will be overwritten - :param merge: if True and ``key`` already exists and both ``data`` and the existing data are dictionaries, they - will be merged - """ - raise NotImplementedError() - - def remove_additional_metadata(self, path, key): - """ - Removes additional metadata under ``key`` for ``name`` on ``path`` - - :param path: the virtual path to the file for which to remove the metadata under ``key`` - :param key: the key to remove - """ - raise NotImplementedError() - - def canonicalize(self, path): - """ - Canonicalizes the given ``path``. The ``path`` may consist of both folder and file name, the underlying - implementation must separate those if necessary. - - By default, this calls :func:`~octoprint.filemanager.StorageInterface.sanitize`, which also takes care - of stripping any invalid characters. - - Args: - path: the path to canonicalize - - Returns: - a 2-tuple containing the canonicalized path and file name - - """ - return self.sanitize(path) - - def sanitize(self, path): - """ - Sanitizes the given ``path``, stripping it of all invalid characters. The ``path`` may consist of both - folder and file name, the underlying implementation must separate those if necessary and sanitize individually. - - :param string path: the path to sanitize - :return: a 2-tuple containing the sanitized path and file name - """ - raise NotImplementedError() - - def sanitize_path(self, path): - """ - Sanitizes the given folder-only ``path``, stripping it of all invalid characters. - :param string path: the path to sanitize - :return: the sanitized path - """ - raise NotImplementedError() - - def sanitize_name(self, name): - """ - Sanitizes the given file ``name``, stripping it of all invalid characters. - :param string name: the file name to sanitize - :return: the sanitized name - """ - raise NotImplementedError() - - def split_path(self, path): - """ - Split ``path`` into base directory and file name. - :param path: the path to split - :return: a tuple (base directory, file name) - """ - raise NotImplementedError() - - def join_path(self, *path): - """ - Join path elements together - :param path: path elements to join - :return: joined representation of the path to be usable as fully qualified path for further operations - """ - raise NotImplementedError() - - def path_on_disk(self, path): - """ - Retrieves the path on disk for ``path``. - - Note: if the storage is not on disk and there exists no path on disk to refer to it, this method should - raise an :class:`io.UnsupportedOperation` - - Opposite of :func:`path_in_storage`. - - :param string path: the virtual path for which to retrieve the path on disk - :return: the path on disk to ``path`` - """ - raise NotImplementedError() - - def path_in_storage(self, path): - """ - Retrieves the equivalent in the storage adapter for ``path``. - - Opposite of :func:`path_on_disk`. - - :param string path: the path for which to retrieve the storage path - :return: the path in storage to ``path`` - """ - raise NotImplementedError() + """ + Interface of storage adapters for OctoPrint. + """ + + # noinspection PyUnreachableCode + @property + def analysis_backlog(self): + """ + Get an iterator over all items stored in the storage that need to be analysed by the :class:`~octoprint.filemanager.AnalysisQueue`. + + The yielded elements are expected as storage specific absolute paths to the respective files. Don't forget + to recurse into folders if your storage adapter supports those. + + :return: an iterator yielding all un-analysed files in the storage + """ + # empty generator pattern, yield is intentionally unreachable + return + yield + + # noinspection PyUnreachableCode + def analysis_backlog_for_path(self, path=None): + # empty generator pattern, yield is intentionally unreachable + return + yield + + def last_modified(self, path=None, recursive=False): + """ + Get the last modification date of the specified ``path`` or ``path``'s subtree. + + Args: + path (str or None): Path for which to determine the subtree's last modification date. If left out or + set to None, defatuls to storage root. + recursive (bool): Whether to determine only the date of the specified ``path`` (False, default) or + the whole ``path``'s subtree (True). + + Returns: (float) The last modification date of the indicated subtree + """ + raise NotImplementedError() + + def file_in_path(self, path, filepath): + """ + Returns whether the file indicated by ``file`` is inside ``path`` or not. + :param string path: the path to check + :param string filepath: path to the file + :return: ``True`` if the file is inside the path, ``False`` otherwise + """ + return NotImplementedError() + + def file_exists(self, path): + """ + Returns whether the file indicated by ``path`` exists or not. + :param string path: the path to check for existence + :return: ``True`` if the file exists, ``False`` otherwise + """ + raise NotImplementedError() + + def folder_exists(self, path): + """ + Returns whether the folder indicated by ``path`` exists or not. + :param string path: the path to check for existence + :return: ``True`` if the folder exists, ``False`` otherwise + """ + raise NotImplementedError() + + def list_files( + self, path=None, filter=None, recursive=True, level=0, force_refresh=False + ): + """ + List all files in storage starting at ``path``. If ``recursive`` is set to True (the default), also dives into + subfolders. + + An optional filter function can be supplied which will be called with a file name and file data and which has + to return True if the file is to be included in the result or False if not. + + The data structure of the returned result will be a dictionary mapping from file names to entry data. File nodes + will contain their metadata here, folder nodes will contain their contained files and folders. Example:: + + { + "some_folder": { + "name": "some_folder", + "path": "some_folder", + "type": "folder", + "children": { + "some_sub_folder": { + "name": "some_sub_folder", + "path": "some_folder/some_sub_folder", + "type": "folder", + "typePath": ["folder"], + "children": { ... } + }, + "some_file.gcode": { + "name": "some_file.gcode", + "path": "some_folder/some_file.gcode", + "type": "machinecode", + "typePath": ["machinecode", "gcode"], + "hash": "", + "links": [ ... ], + ... + }, + ... + } + "test.gcode": { + "name": "test.gcode", + "path": "test.gcode", + "type": "machinecode", + "typePath": ["machinecode", "gcode"], + "hash": "", + "links": [...], + ... + }, + "test.stl": { + "name": "test.stl", + "path": "test.stl", + "type": "model", + "typePath": ["model", "stl"], + "hash": "", + "links": [...], + ... + }, + ... + } + + :param string path: base path from which to recursively list all files, optional, if not supplied listing will start + from root of base folder + :param function filter: a filter that matches the files that are to be returned, may be left out in which case no + filtering will take place + :param bool recursive: will also step into sub folders for building the complete list if set to True, otherwise will only + do one step down into sub folders to be able to populate the ``children``. + :return: a dictionary mapping entry names to entry data that represents the whole file list + """ + raise NotImplementedError() + + def add_folder(self, path, ignore_existing=True, display=None): + """ + Adds a folder as ``path`` + + The ``path`` will be sanitized. + + :param string path: the path of the new folder + :param bool ignore_existing: if set to True, no error will be raised if the folder to be added already exists + :param str display: display name of the folder + :return: the sanitized name of the new folder to be used for future references to the folder + """ + raise NotImplementedError() + + def remove_folder(self, path, recursive=True): + """ + Removes the folder at ``path`` + + :param string path: the path of the folder to remove + :param bool recursive: if set to True, contained folders and files will also be removed, otherwise an error will + be raised if the folder is not empty (apart from any metadata files) when it's to be removed + """ + raise NotImplementedError() + + def copy_folder(self, source, destination): + """ + Copies the folder ``source`` to ``destination`` + + :param string source: path to the source folder + :param string destination: path to destination + + :return: the path in the storage to the copy of the folder + """ + raise NotImplementedError() + + def move_folder(self, source, destination): + """ + Moves the folder ``source`` to ``destination`` + + :param string source: path to the source folder + :param string destination: path to destination + + :return: the new path in the storage to the folder + """ + raise NotImplementedError() + + def add_file( + self, + path, + file_object, + printer_profile=None, + links=None, + allow_overwrite=False, + display=None, + ): + """ + Adds the file ``file_object`` as ``path`` + + :param string path: the file's new path, will be sanitized + :param object file_object: a file object that provides a ``save`` method which will be called with the destination path + where the object should then store its contents + :param object printer_profile: the printer profile associated with this file (if any) + :param list links: any links to add with the file + :param bool allow_overwrite: if set to True no error will be raised if the file already exists and the existing file + and its metadata will just be silently overwritten + :param str display: display name of the file + :return: the sanitized name of the file to be used for future references to it + """ + raise NotImplementedError() + + def remove_file(self, path): + """ + Removes the file at ``path`` + + Will also take care of deleting the corresponding entries + in the metadata and deleting all links pointing to the file. + + :param string path: path of the file to remove + """ + raise NotImplementedError() + + def copy_file(self, source, destination): + """ + Copies the file ``source`` to ``destination`` + + :param string source: path to the source file + :param string destination: path to destination + + :return: the path in the storage to the copy of the file + """ + raise NotImplementedError() + + def move_file(self, source, destination): + """ + Moves the file ``source`` to ``destination`` + + :param string source: path to the source file + :param string destination: path to destination + + :return: the new path in the storage to the file + """ + raise NotImplementedError() + + def has_analysis(self, path): + """ + Returns whether the file at path has been analysed yet + + :param path: virtual path to the file for which to retrieve the metadata + """ + raise NotImplementedError() + + def get_metadata(self, path): + """ + Retrieves the metadata for the file ``path``. + + :param path: virtual path to the file for which to retrieve the metadata + :return: the metadata associated with the file + """ + raise NotImplementedError() + + def add_link(self, path, rel, data): + """ + Adds a link of relation ``rel`` to file ``path`` with the given ``data``. + + The following relation types are currently supported: + + * ``model``: adds a link to a model from which the file was created/sliced, expected additional data is the ``name`` + and optionally the ``hash`` of the file to link to. If the link can be resolved against another file on the + current ``path``, not only will it be added to the links of ``name`` but a reverse link of type ``machinecode`` + referring to ``name`` and its hash will also be added to the linked ``model`` file + * ``machinecode``: adds a link to a file containing machine code created from the current file (model), expected + additional data is the ``name`` and optionally the ``hash`` of the file to link to. If the link can be resolved + against another file on the current ``path``, not only will it be added to the links of ``name`` but a reverse + link of type ``model`` referring to ``name`` and its hash will also be added to the linked ``model`` file. + * ``web``: adds a location on the web associated with this file (e.g. a website where to download a model), + expected additional data is a ``href`` attribute holding the website's URL and optionally a ``retrieved`` + attribute describing when the content was retrieved + + Note that adding ``model`` links to files identifying as models or ``machinecode`` links to files identifying + as machine code will be refused. + + :param path: path of the file for which to add a link + :param rel: type of relation of the link to add (currently ``model``, ``machinecode`` and ``web`` are supported) + :param data: additional data of the link to add + """ + raise NotImplementedError() + + def remove_link(self, path, rel, data): + """ + Removes the link consisting of ``rel`` and ``data`` from file ``name`` on ``path``. + + :param path: path of the file from which to remove the link + :param rel: type of relation of the link to remove (currently ``model``, ``machinecode`` and ``web`` are supported) + :param data: additional data of the link to remove, must match existing link + """ + raise NotImplementedError() + + def get_additional_metadata(self, path, key): + """ + Fetches additional metadata at ``key`` from the metadata of ``path``. + + :param path: the virtual path to the file for which to fetch additional metadata + :param key: key of metadata to fetch + """ + raise NotImplementedError() + + def set_additional_metadata(self, path, key, data, overwrite=False, merge=False): + """ + Adds additional metadata to the metadata of ``path``. Metadata in ``data`` will be saved under ``key``. + + If ``overwrite`` is set and ``key`` already exists in ``name``'s metadata, the current value will be overwritten. + + If ``merge`` is set and ``key`` already exists and both ``data`` and the existing data under ``key`` are dictionaries, + the two dictionaries will be merged recursively. + + :param path: the virtual path to the file for which to add additional metadata + :param key: key of metadata to add + :param data: metadata to add + :param overwrite: if True and ``key`` already exists, it will be overwritten + :param merge: if True and ``key`` already exists and both ``data`` and the existing data are dictionaries, they + will be merged + """ + raise NotImplementedError() + + def remove_additional_metadata(self, path, key): + """ + Removes additional metadata under ``key`` for ``name`` on ``path`` + + :param path: the virtual path to the file for which to remove the metadata under ``key`` + :param key: the key to remove + """ + raise NotImplementedError() + + def canonicalize(self, path): + """ + Canonicalizes the given ``path``. The ``path`` may consist of both folder and file name, the underlying + implementation must separate those if necessary. + + By default, this calls :func:`~octoprint.filemanager.StorageInterface.sanitize`, which also takes care + of stripping any invalid characters. + + Args: + path: the path to canonicalize + + Returns: + a 2-tuple containing the canonicalized path and file name + + """ + return self.sanitize(path) + + def sanitize(self, path): + """ + Sanitizes the given ``path``, stripping it of all invalid characters. The ``path`` may consist of both + folder and file name, the underlying implementation must separate those if necessary and sanitize individually. + + :param string path: the path to sanitize + :return: a 2-tuple containing the sanitized path and file name + """ + raise NotImplementedError() + + def sanitize_path(self, path): + """ + Sanitizes the given folder-only ``path``, stripping it of all invalid characters. + :param string path: the path to sanitize + :return: the sanitized path + """ + raise NotImplementedError() + + def sanitize_name(self, name): + """ + Sanitizes the given file ``name``, stripping it of all invalid characters. + :param string name: the file name to sanitize + :return: the sanitized name + """ + raise NotImplementedError() + + def split_path(self, path): + """ + Split ``path`` into base directory and file name. + :param path: the path to split + :return: a tuple (base directory, file name) + """ + raise NotImplementedError() + + def join_path(self, *path): + """ + Join path elements together + :param path: path elements to join + :return: joined representation of the path to be usable as fully qualified path for further operations + """ + raise NotImplementedError() + + def path_on_disk(self, path): + """ + Retrieves the path on disk for ``path``. + + Note: if the storage is not on disk and there exists no path on disk to refer to it, this method should + raise an :class:`io.UnsupportedOperation` + + Opposite of :func:`path_in_storage`. + + :param string path: the virtual path for which to retrieve the path on disk + :return: the path on disk to ``path`` + """ + raise NotImplementedError() + + def path_in_storage(self, path): + """ + Retrieves the equivalent in the storage adapter for ``path``. + + Opposite of :func:`path_on_disk`. + + :param string path: the path for which to retrieve the storage path + :return: the path in storage to ``path`` + """ + raise NotImplementedError() class StorageError(Exception): - UNKNOWN = "unknown" - INVALID_DIRECTORY = "invalid_directory" - INVALID_FILE = "invalid_file" - INVALID_SOURCE = "invalid_source" - INVALID_DESTINATION = "invalid_destination" - DOES_NOT_EXIST = "does_not_exist" - ALREADY_EXISTS = "already_exists" - SOURCE_EQUALS_DESTINATION = "source_equals_destination" - NOT_EMPTY = "not_empty" - - def __init__(self, message, code=None, cause=None): - BaseException.__init__(self) - self.message = message - self.cause = cause - - if code is None: - code = StorageError.UNKNOWN - self.code = code + UNKNOWN = "unknown" + INVALID_DIRECTORY = "invalid_directory" + INVALID_FILE = "invalid_file" + INVALID_SOURCE = "invalid_source" + INVALID_DESTINATION = "invalid_destination" + DOES_NOT_EXIST = "does_not_exist" + ALREADY_EXISTS = "already_exists" + SOURCE_EQUALS_DESTINATION = "source_equals_destination" + NOT_EMPTY = "not_empty" + + def __init__(self, message, code=None, cause=None): + BaseException.__init__(self) + self.message = message + self.cause = cause + + if code is None: + code = StorageError.UNKNOWN + self.code = code class LocalFileStorage(StorageInterface): - """ - The ``LocalFileStorage`` is a storage implementation which holds all files, folders and metadata on disk. - - Metadata is managed inside ``.metadata.yaml`` files in the respective folders, indexed by the sanitized filenames - stored within the folder. Metadata access is managed through an LRU cache to minimize access overhead. - - This storage type implements :func:`path_on_disk`. - """ - - _UNICODE_VARIATIONS = re.compile(u"[\uFE00-\uFE0F]") - - @classmethod - def _no_unicode_variations(cls, text): - return cls._UNICODE_VARIATIONS.sub(u"", text) - - _SLUGIFY = Slugify() - _SLUGIFY.safe_chars = "-_.()[] " - - @classmethod - def _slugify(cls, text): - text = to_unicode(text) - text = cls._no_unicode_variations(text) - text = demojize(text, delimiters=(u"", u"")) - return cls._SLUGIFY(text) - - def __init__(self, basefolder, create=False): - """ - Initializes a ``LocalFileStorage`` instance under the given ``basefolder``, creating the necessary folder - if necessary and ``create`` is set to ``True``. - - :param string basefolder: the path to the folder under which to create the storage - :param bool create: ``True`` if the folder should be created if it doesn't exist yet, ``False`` otherwise - """ - self._logger = logging.getLogger(__name__) - - self.basefolder = os.path.realpath(os.path.abspath(to_unicode(basefolder))) - if not os.path.exists(self.basefolder) and create: - os.makedirs(self.basefolder) - if not os.path.exists(self.basefolder) or not os.path.isdir(self.basefolder): - raise StorageError("{basefolder} is not a valid directory".format(**locals()), code=StorageError.INVALID_DIRECTORY) - - import threading - self._metadata_lock_mutex = threading.RLock() - self._metadata_locks = dict() - - self._metadata_cache = pylru.lrucache(10) - - self._old_metadata = None - self._initialize_metadata() - - def _initialize_metadata(self): - self._logger.info("Initializing the file metadata for {}...".format(self.basefolder)) - - old_metadata_path = os.path.join(self.basefolder, "metadata.yaml") - backup_path = os.path.join(self.basefolder, "metadata.yaml.backup") - - if os.path.exists(old_metadata_path): - # load the old metadata file - try: - with open(old_metadata_path) as f: - import yaml - self._old_metadata = yaml.safe_load(f) - except: - self._logger.exception("Error while loading old metadata file") - - # make sure the metadata is initialized as far as possible - self._list_folder(self.basefolder) - - # rename the old metadata file - self._old_metadata = None - try: - import shutil - shutil.move(old_metadata_path, backup_path) - except: - self._logger.exception("Could not rename old metadata.yaml file") - - else: - # make sure the metadata is initialized as far as possible - self._list_folder(self.basefolder) - - self._logger.info("... file metadata for {} initialized successfully.".format(self.basefolder)) - - @property - def analysis_backlog(self): - return self.analysis_backlog_for_path() - - def analysis_backlog_for_path(self, path=None): - if path: - path = self.sanitize_path(path) - - for entry in self._analysis_backlog_generator(path): - yield entry - - def _analysis_backlog_generator(self, path=None): - if path is None: - path = self.basefolder - - metadata = self._get_metadata(path) - if not metadata: - metadata = dict() - for entry in scandir(path): - if is_hidden_path(entry.name): - continue - - if entry.is_file() and octoprint.filemanager.valid_file_type(entry.name): - if not entry.name in metadata or not isinstance(metadata[entry.name], dict) or not "analysis" in metadata[entry.name]: - printer_profile_rels = self.get_link(entry.path, "printerprofile") - if printer_profile_rels: - printer_profile_id = printer_profile_rels[0]["id"] - else: - printer_profile_id = None - - yield entry.name, entry.path, printer_profile_id - elif os.path.isdir(entry.path): - for sub_entry in self._analysis_backlog_generator(entry.path): - yield self.join_path(entry.name, sub_entry[0]), sub_entry[1], sub_entry[2] - - def last_modified(self, path=None, recursive=False): - if path is None: - path = self.basefolder - else: - path = os.path.join(self.basefolder, path) - - def last_modified_for_path(p): - metadata = os.path.join(p, ".metadata.yaml") - if os.path.exists(metadata): - return max(os.stat(p).st_mtime, os.stat(metadata).st_mtime) - else: - return os.stat(p).st_mtime - - if recursive: - return max(last_modified_for_path(root) for root, _, _ in walk(path)) - else: - return last_modified_for_path(path) - - def file_in_path(self, path, filepath): - filepath = self.sanitize_path(filepath) - path = self.sanitize_path(path) - - return filepath == path or filepath.startswith(path + os.sep) - - def file_exists(self, path): - path, name = self.sanitize(path) - file_path = os.path.join(path, name) - return os.path.exists(file_path) and os.path.isfile(file_path) - - def folder_exists(self, path): - path, name = self.sanitize(path) - folder_path = os.path.join(path, name) - return os.path.exists(folder_path) and os.path.isdir(folder_path) - - def list_files(self, path=None, filter=None, recursive=True): - if path: - path = self.sanitize_path(to_unicode(path)) - base = self.path_in_storage(path) - if base: - base += u"/" - else: - path = self.basefolder - base = u"" - return self._list_folder(path, base=base, entry_filter=filter, recursive=recursive) - - def add_folder(self, path, ignore_existing=True, display=None): - display_path, display_name = self.canonicalize(path) - path = self.sanitize_path(display_path) - name = self.sanitize_name(display_name) - - if display is not None: - display_name = display - - folder_path = os.path.join(path, name) - if os.path.exists(folder_path): - if not ignore_existing: - raise StorageError("{name} does already exist in {path}".format(**locals()), code=StorageError.ALREADY_EXISTS) - else: - os.mkdir(folder_path) - - if display_name != name: - metadata = self._get_metadata_entry(path, name, default=dict()) - metadata["display"] = display_name - self._update_metadata_entry(path, name, metadata) - - return self.path_in_storage((path, name)) - - def remove_folder(self, path, recursive=True): - path, name = self.sanitize(path) - - folder_path = os.path.join(path, name) - if not os.path.exists(folder_path): - return - - empty = True - for entry in scandir(folder_path): - if entry.name == ".metadata.yaml": - continue - empty = False - break - - if not empty and not recursive: - raise StorageError("{name} in {path} is not empty".format(**locals()), code=StorageError.NOT_EMPTY) - - import shutil - shutil.rmtree(folder_path) - - self._remove_metadata_entry(path, name) - - def _get_source_destination_data(self, source, destination, must_not_equal=False): - """Prepares data dicts about source and destination for copy/move.""" - source_path, source_name = self.sanitize(source) - - destination_canon_path, destination_canon_name = self.canonicalize(destination) - destination_path = self.sanitize_path(destination_canon_path) - destination_name = self.sanitize_name(destination_canon_name) - - source_fullpath = os.path.join(source_path, source_name) - destination_fullpath = os.path.join(destination_path, destination_name) - - if not os.path.exists(source_fullpath): - raise StorageError("{} in {} does not exist".format(source_name, source_path), code=StorageError.INVALID_SOURCE) - - if not os.path.isdir(destination_path): - raise StorageError("Destination path {} does not exist or is not a folder".format(destination_path), code=StorageError.INVALID_DESTINATION) - if os.path.exists(destination_fullpath) and source_fullpath != destination_fullpath: - raise StorageError("{} does already exist in {}".format(destination_name, destination_path), code=StorageError.INVALID_DESTINATION) - - source_meta = self._get_metadata_entry(source_path, source_name) - if source_meta: - source_display = source_meta.get("display", source_name) - else: - source_display = source_name - - if (must_not_equal or source_display == destination_canon_name) and source_fullpath == destination_fullpath: - raise StorageError("Source {} and destination {} are the same folder".format(source_path, destination_path), code=StorageError.SOURCE_EQUALS_DESTINATION) - - source_data = dict( - path=source_path, - name=source_name, - display=source_display, - fullpath=source_fullpath, - ) - destination_data = dict( - path=destination_path, - name=destination_name, - display=destination_canon_name, - fullpath=destination_fullpath, - ) - return source_data, destination_data - - def _set_display_metadata(self, destination_data, source_data=None): - if source_data and destination_data["name"] == source_data["name"] and source_data["name"] != source_data["display"]: - display = source_data["display"] - elif destination_data["name"] != destination_data["display"]: - display = destination_data["display"] - else: - display = None - - destination_meta = self._get_metadata_entry(destination_data["path"], destination_data["name"], - default=dict()) - if display: - destination_meta["display"] = display - self._update_metadata_entry(destination_data["path"], destination_data["name"], destination_meta) - elif "display" in destination_meta: - del destination_meta["display"] - self._update_metadata_entry(destination_data["path"], destination_data["name"], destination_meta) - - def copy_folder(self, source, destination): - source_data, destination_data = self._get_source_destination_data(source, destination, must_not_equal=True) - - try: - shutil.copytree(source_data["fullpath"], destination_data["fullpath"]) - except Exception as e: - raise StorageError("Could not copy %s in %s to %s in %s" % (source_data["name"], source_data["path"], destination_data["name"], destination_data["path"]), cause=e) - - self._set_display_metadata(destination_data, source_data=source_data) - - return self.path_in_storage(destination_data["fullpath"]) - - def move_folder(self, source, destination): - source_data, destination_data = self._get_source_destination_data(source, destination) - - # only a display rename? Update that and bail early - if source_data["fullpath"] == destination_data["fullpath"]: - self._set_display_metadata(destination_data) - return self.path_in_storage(destination_data["fullpath"]) - - try: - shutil.move(source_data["fullpath"], destination_data["fullpath"]) - except Exception as e: - raise StorageError("Could not move %s in %s to %s in %s" % (source_data["name"], source_data["path"], destination_data["name"], destination_data["path"]), cause=e) - - self._set_display_metadata(destination_data, source_data=source_data) - self._remove_metadata_entry(source_data["path"], source_data["name"]) - self._delete_metadata(source_data["fullpath"]) - - return self.path_in_storage(destination_data["fullpath"]) - - def add_file(self, path, file_object, printer_profile=None, links=None, allow_overwrite=False, display=None): - display_path, display_name = self.canonicalize(path) - path = self.sanitize_path(display_path) - name = self.sanitize_name(display_name) - - if display: - display_name = display - - if not octoprint.filemanager.valid_file_type(name): - raise StorageError("{name} is an unrecognized file type".format(**locals()), code=StorageError.INVALID_FILE) - - file_path = os.path.join(path, name) - if os.path.exists(file_path) and not os.path.isfile(file_path): - raise StorageError("{name} does already exist in {path} and is not a file".format(**locals()), code=StorageError.ALREADY_EXISTS) - if os.path.exists(file_path) and not allow_overwrite: - raise StorageError("{name} does already exist in {path} and overwriting is prohibited".format(**locals()), code=StorageError.ALREADY_EXISTS) - - # make sure folders exist - if not os.path.exists(path): - # TODO persist display names of path segments! - os.makedirs(path) - - # save the file - file_object.save(file_path) - - # save the file's hash to the metadata of the folder - file_hash = self._create_hash(file_path) - metadata = self._get_metadata_entry(path, name, default=dict()) - metadata_dirty = False - if not "hash" in metadata or metadata["hash"] != file_hash: - # hash changed -> throw away old metadata - metadata = dict(hash=file_hash) - metadata_dirty = True - - if not "display" in metadata and display_name != name: - # display name is not the same as file name -> store in metadata - metadata["display"] = display_name - metadata_dirty = True - - if metadata_dirty: - self._update_metadata_entry(path, name, metadata) - - # process any links that were also provided for adding to the file - if not links: - links = [] - - if printer_profile is not None: - links.append(("printerprofile", dict(id=printer_profile["id"], name=printer_profile["name"]))) - - self._add_links(name, path, links) - - # touch the file to set last access and modification time to now - os.utime(file_path, None) - - return self.path_in_storage((path, name)) - - def remove_file(self, path): - path, name = self.sanitize(path) - - file_path = os.path.join(path, name) - if not os.path.exists(file_path): - return - if not os.path.isfile(file_path): - raise StorageError("{name} in {path} is not a file".format(**locals()), code=StorageError.INVALID_FILE) - - try: - os.remove(file_path) - except Exception as e: - raise StorageError("Could not delete {name} in {path}".format(**locals()), cause=e) - - self._remove_metadata_entry(path, name) - - def copy_file(self, source, destination): - source_data, destination_data = self._get_source_destination_data(source, destination, must_not_equal=True) - - try: - shutil.copy2(source_data["fullpath"], destination_data["fullpath"]) - except Exception as e: - raise StorageError("Could not copy %s in %s to %s in %s" % (source_data["name"], source_data["path"], destination_data["name"], destination_data["path"]), cause=e) - - self._copy_metadata_entry(source_data["path"], source_data["name"], - destination_data["path"], destination_data["name"]) - self._set_display_metadata(destination_data, source_data=source_data) - - return self.path_in_storage(destination_data["fullpath"]) - - def move_file(self, source, destination, allow_overwrite=False): - source_data, destination_data = self._get_source_destination_data(source, destination) - - # only a display rename? Update that and bail early - if source_data["fullpath"] == destination_data["fullpath"]: - self._set_display_metadata(destination_data) - return self.path_in_storage(destination_data["fullpath"]) - - try: - shutil.move(source_data["fullpath"], destination_data["fullpath"]) - except Exception as e: - raise StorageError("Could not move %s in %s to %s in %s" % (source_data["name"], source_data["path"], destination_data["name"], destination_data["path"]), cause=e) - - self._copy_metadata_entry(source_data["path"], source_data["name"], - destination_data["path"], destination_data["name"], - delete_source=True) - self._set_display_metadata(destination_data, source_data=source_data) - - return self.path_in_storage(destination_data["fullpath"]) - - def has_analysis(self, path): - metadata = self.get_metadata(path) - return "analysis" in metadata - - def get_metadata(self, path): - path, name = self.sanitize(path) - return self._get_metadata_entry(path, name) - - def get_link(self, path, rel): - path, name = self.sanitize(path) - return self._get_links(name, path, rel) - - def add_link(self, path, rel, data): - path, name = self.sanitize(path) - self._add_links(name, path, [(rel, data)]) - - def remove_link(self, path, rel, data): - path, name = self.sanitize(path) - self._remove_links(name, path, [(rel, data)]) - - def add_history(self, path, data): - path, name = self.sanitize(path) - self._add_history(name, path, data) - - def update_history(self, path, index, data): - path, name = self.sanitize(path) - self._update_history(name, path, index, data) - - def remove_history(self, path, index): - path, name = self.sanitize(path) - self._delete_history(name, path, index) - - def set_additional_metadata(self, path, key, data, overwrite=False, merge=False): - path, name = self.sanitize(path) - metadata = self._get_metadata(path) - metadata_dirty = False - - if not name in metadata: - return - - if not key in metadata[name] or overwrite: - metadata[name][key] = data - metadata_dirty = True - elif key in metadata[name] and isinstance(metadata[name][key], dict) and isinstance(data, dict) and merge: - current_data = metadata[name][key] - - import octoprint.util - new_data = octoprint.util.dict_merge(current_data, data) - metadata[name][key] = new_data - metadata_dirty = True - - if metadata_dirty: - self._save_metadata(path, metadata) - - def remove_additional_metadata(self, path, key): - path, name = self.sanitize(path) - metadata = self._get_metadata(path) - - if not name in metadata: - return - - if not key in metadata[name]: - return - - del metadata[name][key] - self._save_metadata(path, metadata) - - def split_path(self, path): - path = to_unicode(path) - split = path.split(u"/") - if len(split) == 1: - return u"", split[0] - else: - return self.join_path(*split[:-1]), split[-1] - - def join_path(self, *path): - return u"/".join(map(to_unicode, path)) - - def sanitize(self, path): - """ - Returns a ``(path, name)`` tuple derived from the provided ``path``. - - ``path`` may be: - * a storage path - * an absolute file system path - * a tuple or list containing all individual path elements - * a string representation of the path - * with or without a file name - - Note that for a ``path`` without a trailing slash the last part will be considered a file name and - hence be returned at second position. If you only need to convert a folder path, be sure to - include a trailing slash for a string ``path`` or an empty last element for a list ``path``. - """ - - path, name = self.canonicalize(path) - name = self.sanitize_name(name) - path = self.sanitize_path(path) - return path, name - - def canonicalize(self, path): - name = None - if isinstance(path, basestring): - path = to_unicode(path) - if path.startswith(self.basefolder): - path = path[len(self.basefolder):] - path = path.replace(os.path.sep, u"/") - path = path.split(u"/") - if isinstance(path, (list, tuple)): - if len(path) == 1: - name = to_unicode(path[0]) - path = u"" - else: - name = to_unicode(path[-1]) - path = self.join_path(*map(to_unicode, path[:-1])) - if not path: - path = u"" - - return path, name - - def sanitize_name(self, name): - """ - Raises a :class:`ValueError` for a ``name`` containing ``/`` or ``\``. Otherwise - slugifies the given ``name`` by converting it to ASCII, leaving ``-``, ``_``, ``.``, - ``(``, and ``)`` as is. - """ - name = to_unicode(name) - - if name is None: - return None - - if u"/" in name or u"\\" in name: - raise ValueError("name must not contain / or \\") - - result = self._slugify(name).replace(u" ", u"_") - if result and result != u"." and result != u".." and result[0] == u".": - # hidden files under *nix - result = result[1:] - return result - - def sanitize_path(self, path): - """ - Ensures that the on disk representation of ``path`` is located under the configured basefolder. Resolves all - relative path elements (e.g. ``..``) and sanitizes folder names using :func:`sanitize_name`. Final path is the - absolute path including leading ``basefolder`` path. - """ - path = to_unicode(path) - - if len(path): - if path[0] == u"/": - path = path[1:] - elif path[0] == u"." and path[1] == u"/": - path = path[2:] - - path_elements = path.split(u"/") - joined_path = self.basefolder - for path_element in path_elements: - joined_path = os.path.join(joined_path, self.sanitize_name(path_element)) - path = os.path.realpath(joined_path) - if not path.startswith(self.basefolder): - raise ValueError("path not contained in base folder: {path}".format(**locals())) - return path - - def _sanitize_entry(self, entry, path, entry_path): - entry = to_unicode(entry) - sanitized = self.sanitize_name(entry) - if sanitized != entry: - # entry is not sanitized yet, let's take care of that - sanitized_path = os.path.join(path, sanitized) - sanitized_name, sanitized_ext = os.path.splitext(sanitized) - - counter = 1 - while os.path.exists(sanitized_path): - counter += 1 - sanitized = self.sanitize_name(u"{}_({}){}".format(sanitized_name, counter, sanitized_ext)) - sanitized_path = os.path.join(path, sanitized) - - try: - shutil.move(entry_path, sanitized_path) - - self._logger.info(u"Sanitized \"{}\" to \"{}\"".format(entry_path, sanitized_path)) - return sanitized, sanitized_path - except: - self._logger.exception(u"Error while trying to rename \"{}\" to \"{}\", ignoring file".format(entry_path, sanitized_path)) - raise - - return entry, entry_path - - def path_in_storage(self, path): - if isinstance(path, (tuple, list)): - path = self.join_path(*path) - if isinstance(path, (str, unicode, basestring)): - path = to_unicode(path) - if path.startswith(self.basefolder): - path = path[len(self.basefolder):] - path = path.replace(os.path.sep, u"/") - if path.startswith(u"/"): - path = path[1:] - - return path - - def path_on_disk(self, path): - path, name = self.sanitize(path) - return os.path.join(path, name) - - ##~~ internals - - def _add_history(self, name, path, data): - metadata = self._get_metadata(path) - - if not name in metadata: - metadata[name] = dict() - - if not "hash" in metadata[name]: - metadata[name]["hash"] = self._create_hash(os.path.join(path, name)) - - if not "history" in metadata[name]: - metadata[name]["history"] = [] - - metadata[name]["history"].append(data) - self._calculate_stats_from_history(name, path, metadata=metadata, save=False) - self._save_metadata(path, metadata) - - def _update_history(self, name, path, index, data): - metadata = self._get_metadata(path) - - if not name in metadata or not "history" in metadata[name]: - return - - try: - metadata[name]["history"][index].update(data) - self._calculate_stats_from_history(name, path, metadata=metadata, save=False) - self._save_metadata(path, metadata) - except IndexError: - pass - - def _delete_history(self, name, path, index): - metadata = self._get_metadata(path) - - if not name in metadata or not "history" in metadata[name]: - return - - try: - del metadata[name]["history"][index] - self._calculate_stats_from_history(name, path, metadata=metadata, save=False) - self._save_metadata(path, metadata) - except IndexError: - pass - - def _calculate_stats_from_history(self, name, path, metadata=None, save=True): - if metadata is None: - metadata = self._get_metadata(path) - - if not name in metadata or not "history" in metadata[name]: - return - - # collect data from history - former_print_times = dict() - last_print = dict() - - - for history_entry in metadata[name]["history"]: - if not "printTime" in history_entry or not "success" in history_entry or not history_entry["success"] or not "printerProfile" in history_entry: - continue - - printer_profile = history_entry["printerProfile"] - if not printer_profile: - continue - - print_time = history_entry["printTime"] - try: - print_time = float(print_time) - except: - self._logger.warn("Invalid print time value found in print history for {} in {}/.metadata.yaml: {!r}".format(name, path, print_time)) - continue - - if not printer_profile in former_print_times: - former_print_times[printer_profile] = [] - former_print_times[printer_profile].append(print_time) - - if not printer_profile in last_print or last_print[printer_profile] is None or ("timestamp" in history_entry and history_entry["timestamp"] > last_print[printer_profile]["timestamp"]): - last_print[printer_profile] = history_entry - - # calculate stats - statistics = dict(averagePrintTime=dict(), lastPrintTime=dict()) - - for printer_profile in former_print_times: - if not former_print_times[printer_profile]: - continue - statistics["averagePrintTime"][printer_profile] = sum(former_print_times[printer_profile]) / float(len(former_print_times[printer_profile])) - - for printer_profile in last_print: - if not last_print[printer_profile]: - continue - statistics["lastPrintTime"][printer_profile] = last_print[printer_profile]["printTime"] - - metadata[name]["statistics"] = statistics - - if save: - self._save_metadata(path, metadata) - - def _get_links(self, name, path, searched_rel): - metadata = self._get_metadata(path) - result = [] - - if not name in metadata: - return result - - if not "links" in metadata[name]: - return result - - for data in metadata[name]["links"]: - if not "rel" in data or not data["rel"] == searched_rel: - continue - result.append(data) - return result - - def _add_links(self, name, path, links): - file_type = octoprint.filemanager.get_file_type(name) - if file_type: - file_type = file_type[0] - - metadata = self._get_metadata(path) - metadata_dirty = False - - if not name in metadata: - metadata[name] = dict() - - if not "hash" in metadata[name]: - metadata[name]["hash"] = self._create_hash(os.path.join(path, name)) - - if not "links" in metadata[name]: - metadata[name]["links"] = [] - - for rel, data in links: - if (rel == "model" or rel == "machinecode") and "name" in data: - if file_type == "model" and rel == "model": - # adding a model link to a model doesn't make sense - return - elif file_type == "machinecode" and rel == "machinecode": - # adding a machinecode link to a machinecode doesn't make sense - return - - ref_path = os.path.join(path, data["name"]) - if not os.path.exists(ref_path): - # file doesn't exist, we won't create the link - continue - - # fetch hash of target file - if data["name"] in metadata and "hash" in metadata[data["name"]]: - hash = metadata[data["name"]]["hash"] - else: - hash = self._create_hash(ref_path) - if not data["name"] in metadata: - metadata[data["name"]] = dict( - hash=hash, - links=[] - ) - else: - metadata[data["name"]]["hash"] = hash - - if "hash" in data and not data["hash"] == hash: - # file doesn't have the correct hash, we won't create the link - continue - - if not "links" in metadata[data["name"]]: - metadata[data["name"]]["links"] = [] - - # add reverse link to link target file - metadata[data["name"]]["links"].append( - dict(rel="machinecode" if rel == "model" else "model", name=name, hash=metadata[name]["hash"]) - ) - metadata_dirty = True - - link_dict = dict( - rel=rel, - name=data["name"], - hash=hash - ) - - elif rel == "web" and "href" in data: - link_dict = dict( - rel=rel, - href=data["href"] - ) - if "retrieved" in data: - link_dict["retrieved"] = data["retrieved"] - - else: - continue - - if link_dict: - metadata[name]["links"].append(link_dict) - metadata_dirty = True - - if metadata_dirty: - self._save_metadata(path, metadata) - - def _remove_links(self, name, path, links): - metadata = self._get_metadata(path) - metadata_dirty = False - - if not name in metadata or not "hash" in metadata[name]: - hash = self._create_hash(os.path.join(path, name)) - else: - hash = metadata[name]["hash"] - - for rel, data in links: - if (rel == "model" or rel == "machinecode") and "name" in data: - if data["name"] in metadata and "links" in metadata[data["name"]]: - ref_rel = "model" if rel == "machinecode" else "machinecode" - for link in metadata[data["name"]]["links"]: - if link["rel"] == ref_rel and "name" in link and link["name"] == name and "hash" in link and link["hash"] == hash: - metadata[data["name"]]["links"].remove(link) - metadata_dirty = True - - if "links" in metadata[name]: - for link in metadata[name]["links"]: - if not link["rel"] == rel: - continue - - matches = True - for k, v in data.items(): - if not k in link or not link[k] == v: - matches = False - break - - if not matches: - continue - - metadata[name]["links"].remove(link) - metadata_dirty = True - - if metadata_dirty: - self._save_metadata(path, metadata) - - def _list_folder(self, path, base="", entry_filter=None, recursive=True, **kwargs): - if entry_filter is None: - entry_filter = kwargs.get("filter", None) - - metadata = self._get_metadata(path) - if not metadata: - metadata = dict() - metadata_dirty = False - - result = dict() - for entry in scandir(path): - if is_hidden_path(entry.name): - # no hidden files and folders - continue - - try: - entry_name = entry_display = entry.name - entry_path = entry.path - entry_is_file = entry.is_file() - entry_is_dir = entry.is_dir() - entry_stat = entry.stat() - except: - # error while trying to fetch file metadata, that might be thanks to file already having - # been moved or deleted - ignore it and continue - continue - - try: - new_entry_name, new_entry_path = self._sanitize_entry(entry_name, path, entry_path) - if entry_name != new_entry_name or entry_path != new_entry_path: - entry_display = to_unicode(entry_name) - entry_name = new_entry_name - entry_path = new_entry_path - entry_stat = os.stat(entry_path) - except: - # error while trying to rename the file, we'll continue here and ignore it - continue - - path_in_location = entry_name if not base else base + entry_name - - # file handling - if entry_is_file: - type_path = octoprint.filemanager.get_file_type(entry_name) - if not type_path: - # only supported extensions - continue - else: - file_type = type_path[0] - - if entry_name in metadata and isinstance(metadata[entry_name], dict): - entry_metadata = metadata[entry_name] - if not "display" in entry_metadata and entry_display != entry_name: - metadata[entry_name]["display"] = entry_display - entry_metadata["display"] = entry_display - metadata_dirty = True - else: - entry_metadata = self._add_basic_metadata(path, entry_name, - display_name=entry_display, - save=False, - metadata=metadata) - metadata_dirty = True - - # TODO extract model hash from source if possible to recreate link - - if not entry_filter or entry_filter(entry_name, entry_metadata): - # only add files passing the optional filter - extended_entry_data = dict() - extended_entry_data.update(entry_metadata) - extended_entry_data["name"] = entry_name - extended_entry_data["display"] = entry_metadata.get("display", entry_name) - extended_entry_data["path"] = path_in_location - extended_entry_data["type"] = file_type - extended_entry_data["typePath"] = type_path - stat = entry_stat - if stat: - extended_entry_data["size"] = stat.st_size - extended_entry_data["date"] = int(stat.st_mtime) - - result[entry_name] = extended_entry_data - - # folder recursion - elif entry_is_dir: - if entry_name in metadata and isinstance(metadata[entry_name], dict): - entry_metadata = metadata[entry_name] - if not "display" in entry_metadata and entry_display != entry_name: - metadata[entry_name]["display"] = entry_display - entry_metadata["display"] = entry_display - metadata_dirty = True - elif entry_name != entry_display: - entry_metadata = self._add_basic_metadata(path, entry_name, - display_name=entry_display, - save=False, - metadata=metadata) - metadata_dirty = True - else: - entry_metadata = dict() - - entry_data = dict( - name=entry_name, - display=entry_metadata.get("display", entry_name), - path=path_in_location, - type="folder", - typePath=["folder"] - ) - if recursive: - sub_result = self._list_folder(entry_path, base=path_in_location + "/", entry_filter=entry_filter, - recursive=recursive) - entry_data["children"] = sub_result - - if not entry_filter or entry_filter(entry_name, entry_data): - def get_size(): - total_size = 0 - for element in entry_data["children"].values(): - if "size" in element: - total_size += element["size"] - - return total_size - - # only add folders passing the optional filter - extended_entry_data = dict() - extended_entry_data.update(entry_data) - if recursive: - extended_entry_data["size"] = get_size() - - result[entry_name] = extended_entry_data - - # TODO recreate links if we have metadata less entries - - # save metadata - if metadata_dirty: - self._save_metadata(path, metadata) - - return result - - def _add_basic_metadata(self, path, entry, display_name=None, additional_metadata=None, save=True, metadata=None): - if additional_metadata is None: - additional_metadata = dict() - - if metadata is None: - metadata = self._get_metadata(path) - - entry_path = os.path.join(path, entry) - - if os.path.isfile(entry_path): - entry_data = dict( - hash=self._create_hash(os.path.join(path, entry)), - links=[], - notes=[] - ) - if path == self.basefolder and self._old_metadata is not None and entry in self._old_metadata and "gcodeAnalysis" in self._old_metadata[entry]: - # if there is still old metadata available and that contains an analysis for this file, use it! - entry_data["analysis"] = self._old_metadata[entry]["gcodeAnalysis"] - - elif os.path.isdir(entry_path): - entry_data = dict() - - else: - return - - if display_name is not None and not display_name == entry: - entry_data["display"] = display_name - - entry_data.update(additional_metadata) - metadata[entry] = entry_data - - if save: - self._save_metadata(path, metadata) - - return entry_data - - def _create_hash(self, path): - import hashlib - - blocksize = 65536 - hash = hashlib.sha1() - with open(path, "rb") as f: - buffer = f.read(blocksize) - while len(buffer) > 0: - hash.update(buffer) - buffer = f.read(blocksize) - - return hash.hexdigest() - - def _get_metadata_entry(self, path, name, default=None): - with self._get_metadata_lock(path): - metadata = self._get_metadata(path) - return metadata.get(name, default) - - def _remove_metadata_entry(self, path, name): - with self._get_metadata_lock(path): - metadata = self._get_metadata(path) - if not name in metadata: - return - - if "hash" in metadata[name]: - hash = metadata[name]["hash"] - for m in metadata.values(): - if not "links" in m: - continue - links_hash = lambda link: "hash" in link and link["hash"] == hash and "rel" in link and (link["rel"] == "model" or link["rel"] == "machinecode") - m["links"] = [link for link in m["links"] if not links_hash(link)] - - del metadata[name] - self._save_metadata(path, metadata) - - def _update_metadata_entry(self, path, name, data): - with self._get_metadata_lock(path): - metadata = self._get_metadata(path) - metadata[name] = data - self._save_metadata(path, metadata) - - def _copy_metadata_entry(self, source_path, source_name, destination_path, destination_name, delete_source=False, updates=None): - with self._get_metadata_lock(source_path): - source_data = self._get_metadata_entry(source_path, source_name, default=dict()) - if not source_data: - return - - if delete_source: - self._remove_metadata_entry(source_path, source_name) - - if updates is not None: - source_data.update(updates) - - with self._get_metadata_lock(destination_path): - self._update_metadata_entry(destination_path, destination_name, source_data) - - def _get_metadata(self, path): - with self._get_metadata_lock(path): - if path in self._metadata_cache: - return deepcopy(self._metadata_cache[path]) - - metadata_path = os.path.join(path, ".metadata.yaml") - if os.path.exists(metadata_path): - with open(metadata_path) as f: - try: - import yaml - metadata = yaml.safe_load(f) - except: - self._logger.exception("Error while reading .metadata.yaml from {path}".format(**locals())) - else: - if isinstance(metadata, dict): - self._metadata_cache[path] = deepcopy(metadata) - return metadata - return dict() - - def _save_metadata(self, path, metadata): - with self._get_metadata_lock(path): - metadata_path = os.path.join(path, ".metadata.yaml") - try: - import yaml - with atomic_write(metadata_path) as f: - yaml.safe_dump(metadata, stream=f, default_flow_style=False, indent=" ", allow_unicode=True) - except: - self._logger.exception("Error while writing .metadata.yaml to {path}".format(**locals())) - else: - self._metadata_cache[path] = deepcopy(metadata) - - def _delete_metadata(self, path): - with self._get_metadata_lock(path): - metadata_path = os.path.join(path, ".metadata.yaml") - if os.path.exists(metadata_path): - try: - os.remove(metadata_path) - except: - self._logger.exception("Error while deleting .metadata.yaml from {path}".format(**locals())) - if path in self._metadata_cache: - del self._metadata_cache[path] - - @contextmanager - def _get_metadata_lock(self, path): - with self._metadata_lock_mutex: - if path not in self._metadata_locks: - import threading - self._metadata_locks[path] = (0, threading.RLock()) - - counter, lock = self._metadata_locks[path] - counter += 1 - self._metadata_locks[path] = (counter, lock) - - yield lock - - counter = self._metadata_locks[path][0] - counter -= 1 - if counter <= 0: - del self._metadata_locks[path] - else: - self._metadata_locks[path] = (counter, lock) + """ + The ``LocalFileStorage`` is a storage implementation which holds all files, folders and metadata on disk. + + Metadata is managed inside ``.metadata.json`` files in the respective folders, indexed by the sanitized filenames + stored within the folder. Metadata access is managed through an LRU cache to minimize access overhead. + + This storage type implements :func:`path_on_disk`. + """ + + def __init__(self, basefolder, create=False, really_universal=False): + """ + Initializes a ``LocalFileStorage`` instance under the given ``basefolder``, creating the necessary folder + if necessary and ``create`` is set to ``True``. + + :param string basefolder: the path to the folder under which to create the storage + :param bool create: ``True`` if the folder should be created if it doesn't exist yet, ``False`` otherwise + :param bool really_universal: ``True`` if the file names should be forced to really universal, ``False`` otherwise + """ + self._logger = logging.getLogger(__name__) + + self.basefolder = os.path.realpath(os.path.abspath(to_unicode(basefolder))) + if not os.path.exists(self.basefolder) and create: + os.makedirs(self.basefolder) + if not os.path.exists(self.basefolder) or not os.path.isdir(self.basefolder): + raise StorageError( + "{basefolder} is not a valid directory".format(**locals()), + code=StorageError.INVALID_DIRECTORY, + ) + + self._really_universal = really_universal + + import threading + + self._metadata_lock_mutex = threading.RLock() + self._metadata_locks = {} + self._persisted_metadata_lock_mutex = threading.RLock() + self._persisted_metadata_locks = {} + + self._metadata_cache = pylru.lrucache(100) + self._filelist_cache = {} + self._filelist_cache_mutex = threading.RLock() + + self._old_metadata = None + self._initialize_metadata() + + def _initialize_metadata(self): + self._logger.info( + "Initializing the file metadata for {}...".format(self.basefolder) + ) + + old_metadata_path = os.path.join(self.basefolder, "metadata.yaml") + backup_path = os.path.join(self.basefolder, "metadata.yaml.backup") + + if os.path.exists(old_metadata_path): + # load the old metadata file + try: + with io.open(old_metadata_path, "rt", encoding="utf-8") as f: + import yaml + + self._old_metadata = yaml.safe_load(f) + except Exception: + self._logger.exception("Error while loading old metadata file") + + # make sure the metadata is initialized as far as possible + self._list_folder(self.basefolder) + + # rename the old metadata file + self._old_metadata = None + try: + import shutil + + shutil.move(old_metadata_path, backup_path) + except Exception: + self._logger.exception("Could not rename old metadata.yaml file") + + else: + # make sure the metadata is initialized as far as possible + self._list_folder(self.basefolder) + + self._logger.info( + "... file metadata for {} initialized successfully.".format(self.basefolder) + ) + + @property + def analysis_backlog(self): + return self.analysis_backlog_for_path() + + def analysis_backlog_for_path(self, path=None): + if path: + path = self.sanitize_path(path) + + for entry in self._analysis_backlog_generator(path): + yield entry + + def _analysis_backlog_generator(self, path=None): + if path is None: + path = self.basefolder + + metadata = self._get_metadata(path) + if not metadata: + metadata = {} + for entry in scandir(path): + if is_hidden_path(entry.name): + continue + + if entry.is_file() and octoprint.filemanager.valid_file_type(entry.name): + if ( + entry.name not in metadata + or not isinstance(metadata[entry.name], dict) + or "analysis" not in metadata[entry.name] + ): + printer_profile_rels = self.get_link(entry.path, "printerprofile") + if printer_profile_rels: + printer_profile_id = printer_profile_rels[0]["id"] + else: + printer_profile_id = None + + yield entry.name, entry.path, printer_profile_id + elif os.path.isdir(entry.path): + for sub_entry in self._analysis_backlog_generator(entry.path): + yield self.join_path(entry.name, sub_entry[0]), sub_entry[ + 1 + ], sub_entry[2] + + def last_modified(self, path=None, recursive=False): + if path is None: + path = self.basefolder + else: + path = os.path.join(self.basefolder, path) + + def last_modified_for_path(p): + metadata = os.path.join(p, ".metadata.json") + if os.path.exists(metadata): + return max(os.stat(p).st_mtime, os.stat(metadata).st_mtime) + else: + return os.stat(p).st_mtime + + if recursive: + return max(last_modified_for_path(root) for root, _, _ in walk(path)) + else: + return last_modified_for_path(path) + + def file_in_path(self, path, filepath): + filepath = self.sanitize_path(filepath) + path = self.sanitize_path(path) + + return filepath == path or filepath.startswith(path + os.sep) + + def file_exists(self, path): + path, name = self.sanitize(path) + file_path = os.path.join(path, name) + return os.path.exists(file_path) and os.path.isfile(file_path) + + def folder_exists(self, path): + path, name = self.sanitize(path) + folder_path = os.path.join(path, name) + return os.path.exists(folder_path) and os.path.isdir(folder_path) + + def list_files( + self, path=None, filter=None, recursive=True, level=0, force_refresh=False + ): + if path: + path = self.sanitize_path(to_unicode(path)) + base = self.path_in_storage(path) + if base: + base += "/" + else: + path = self.basefolder + base = "" + + def strip_children(nodes): + result = {} + for key, node in nodes.items(): + if node["type"] == "folder": + node = copy.copy(node) + node["children"] = {} + result[key] = node + return result + + def strip_grandchildren(nodes): + result = {} + for key, node in nodes.items(): + if node["type"] == "folder": + node = copy.copy(node) + node["children"] = strip_children(node["children"]) + result[key] = node + return result + + def apply_filter(nodes, filter_func): + result = {} + for key, node in nodes.items(): + if filter_func(node) or node["type"] == "folder": + if node["type"] == "folder": + node = copy.copy(node) + node["children"] = apply_filter( + node.get("children", {}), filter_func + ) + result[key] = node + return result + + result = self._list_folder(path, base=base, force_refresh=force_refresh) + if not recursive: + if level > 0: + result = strip_grandchildren(result) + else: + result = strip_children(result) + if callable(filter): + result = apply_filter(result, filter) + return result + + def add_folder(self, path, ignore_existing=True, display=None): + display_path, display_name = self.canonicalize(path) + path = self.sanitize_path(display_path) + name = self.sanitize_name(display_name) + + if display is not None: + display_name = display + + folder_path = os.path.join(path, name) + if os.path.exists(folder_path): + if not ignore_existing: + raise StorageError( + "{name} does already exist in {path}".format(**locals()), + code=StorageError.ALREADY_EXISTS, + ) + else: + os.mkdir(folder_path) + + if display_name != name: + metadata = self._get_metadata_entry(path, name, default={}) + metadata["display"] = display_name + self._update_metadata_entry(path, name, metadata) + + return self.path_in_storage((path, name)) + + def remove_folder(self, path, recursive=True): + path, name = self.sanitize(path) + + folder_path = os.path.join(path, name) + if not os.path.exists(folder_path): + return + + empty = True + for entry in scandir(folder_path): + if entry.name == ".metadata.json" or entry.name == ".metadata.yaml": + continue + empty = False + break + + if not empty and not recursive: + raise StorageError( + "{name} in {path} is not empty".format(**locals()), + code=StorageError.NOT_EMPTY, + ) + + import shutil + + shutil.rmtree(folder_path) + + self._remove_metadata_entry(path, name) + + def _get_source_destination_data(self, source, destination, must_not_equal=False): + """Prepares data dicts about source and destination for copy/move.""" + source_path, source_name = self.sanitize(source) + + destination_canon_path, destination_canon_name = self.canonicalize(destination) + destination_path = self.sanitize_path(destination_canon_path) + destination_name = self.sanitize_name(destination_canon_name) + + source_fullpath = os.path.join(source_path, source_name) + destination_fullpath = os.path.join(destination_path, destination_name) + + if not os.path.exists(source_fullpath): + raise StorageError( + "{} in {} does not exist".format(source_name, source_path), + code=StorageError.INVALID_SOURCE, + ) + + if not os.path.isdir(destination_path): + raise StorageError( + "Destination path {} does not exist or is not a folder".format( + destination_path + ), + code=StorageError.INVALID_DESTINATION, + ) + if ( + os.path.exists(destination_fullpath) + and source_fullpath != destination_fullpath + ): + raise StorageError( + "{} does already exist in {}".format(destination_name, destination_path), + code=StorageError.INVALID_DESTINATION, + ) + + source_meta = self._get_metadata_entry(source_path, source_name) + if source_meta: + source_display = source_meta.get("display", source_name) + else: + source_display = source_name + + if ( + must_not_equal or source_display == destination_canon_name + ) and source_fullpath == destination_fullpath: + raise StorageError( + "Source {} and destination {} are the same folder".format( + source_path, destination_path + ), + code=StorageError.SOURCE_EQUALS_DESTINATION, + ) + + source_data = { + "path": source_path, + "name": source_name, + "display": source_display, + "fullpath": source_fullpath, + } + destination_data = { + "path": destination_path, + "name": destination_name, + "display": destination_canon_name, + "fullpath": destination_fullpath, + } + return source_data, destination_data + + def _set_display_metadata(self, destination_data, source_data=None): + if ( + source_data + and destination_data["name"] == source_data["name"] + and source_data["name"] != source_data["display"] + ): + display = source_data["display"] + elif destination_data["name"] != destination_data["display"]: + display = destination_data["display"] + else: + display = None + + destination_meta = self._get_metadata_entry( + destination_data["path"], destination_data["name"], default={} + ) + if display: + destination_meta["display"] = display + self._update_metadata_entry( + destination_data["path"], destination_data["name"], destination_meta + ) + elif "display" in destination_meta: + del destination_meta["display"] + self._update_metadata_entry( + destination_data["path"], destination_data["name"], destination_meta + ) + + def copy_folder(self, source, destination): + source_data, destination_data = self._get_source_destination_data( + source, destination, must_not_equal=True + ) + + try: + shutil.copytree(source_data["fullpath"], destination_data["fullpath"]) + except Exception as e: + raise StorageError( + "Could not copy %s in %s to %s in %s" + % ( + source_data["name"], + source_data["path"], + destination_data["name"], + destination_data["path"], + ), + cause=e, + ) + + self._set_display_metadata(destination_data, source_data=source_data) + + return self.path_in_storage(destination_data["fullpath"]) + + def move_folder(self, source, destination): + source_data, destination_data = self._get_source_destination_data( + source, destination + ) + + # only a display rename? Update that and bail early + if source_data["fullpath"] == destination_data["fullpath"]: + self._set_display_metadata(destination_data) + return self.path_in_storage(destination_data["fullpath"]) + + try: + shutil.move(source_data["fullpath"], destination_data["fullpath"]) + except Exception as e: + raise StorageError( + "Could not move %s in %s to %s in %s" + % ( + source_data["name"], + source_data["path"], + destination_data["name"], + destination_data["path"], + ), + cause=e, + ) + + self._set_display_metadata(destination_data, source_data=source_data) + self._remove_metadata_entry(source_data["path"], source_data["name"]) + self._delete_metadata(source_data["fullpath"]) + + return self.path_in_storage(destination_data["fullpath"]) + + def add_file( + self, + path, + file_object, + printer_profile=None, + links=None, + allow_overwrite=False, + display=None, + ): + display_path, display_name = self.canonicalize(path) + path = self.sanitize_path(display_path) + name = self.sanitize_name(display_name) + + if display: + display_name = display + + if not octoprint.filemanager.valid_file_type(name): + raise StorageError( + "{name} is an unrecognized file type".format(**locals()), + code=StorageError.INVALID_FILE, + ) + + file_path = os.path.join(path, name) + if os.path.exists(file_path) and not os.path.isfile(file_path): + raise StorageError( + "{name} does already exist in {path} and is not a file".format( + **locals() + ), + code=StorageError.ALREADY_EXISTS, + ) + if os.path.exists(file_path) and not allow_overwrite: + raise StorageError( + "{name} does already exist in {path} and overwriting is prohibited".format( + **locals() + ), + code=StorageError.ALREADY_EXISTS, + ) + + # make sure folders exist + if not os.path.exists(path): + # TODO persist display names of path segments! + os.makedirs(path) + + # save the file + file_object.save(file_path) + + # save the file's hash to the metadata of the folder + file_hash = self._create_hash(file_path) + metadata = self._get_metadata_entry(path, name, default={}) + metadata_dirty = False + if "hash" not in metadata or metadata["hash"] != file_hash: + # hash changed -> throw away old metadata + metadata = {"hash": file_hash} + metadata_dirty = True + + if "display" not in metadata and display_name != name: + # display name is not the same as file name -> store in metadata + metadata["display"] = display_name + metadata_dirty = True + + if metadata_dirty: + self._update_metadata_entry(path, name, metadata) + + # process any links that were also provided for adding to the file + if not links: + links = [] + + if printer_profile is not None: + links.append( + ( + "printerprofile", + {"id": printer_profile["id"], "name": printer_profile["name"]}, + ) + ) + + self._add_links(name, path, links) + + # touch the file to set last access and modification time to now + os.utime(file_path, None) + + return self.path_in_storage((path, name)) + + def remove_file(self, path): + path, name = self.sanitize(path) + + file_path = os.path.join(path, name) + if not os.path.exists(file_path): + return + if not os.path.isfile(file_path): + raise StorageError( + "{name} in {path} is not a file".format(**locals()), + code=StorageError.INVALID_FILE, + ) + + try: + os.remove(file_path) + except Exception as e: + raise StorageError( + "Could not delete {name} in {path}".format(**locals()), cause=e + ) + + self._remove_metadata_entry(path, name) + + def copy_file(self, source, destination): + source_data, destination_data = self._get_source_destination_data( + source, destination, must_not_equal=True + ) + + try: + shutil.copy2(source_data["fullpath"], destination_data["fullpath"]) + except Exception as e: + raise StorageError( + "Could not copy %s in %s to %s in %s" + % ( + source_data["name"], + source_data["path"], + destination_data["name"], + destination_data["path"], + ), + cause=e, + ) + + self._copy_metadata_entry( + source_data["path"], + source_data["name"], + destination_data["path"], + destination_data["name"], + ) + self._set_display_metadata(destination_data, source_data=source_data) + + return self.path_in_storage(destination_data["fullpath"]) + + def move_file(self, source, destination, allow_overwrite=False): + source_data, destination_data = self._get_source_destination_data( + source, destination + ) + + # only a display rename? Update that and bail early + if source_data["fullpath"] == destination_data["fullpath"]: + self._set_display_metadata(destination_data) + return self.path_in_storage(destination_data["fullpath"]) + + try: + shutil.move(source_data["fullpath"], destination_data["fullpath"]) + except Exception as e: + raise StorageError( + "Could not move %s in %s to %s in %s" + % ( + source_data["name"], + source_data["path"], + destination_data["name"], + destination_data["path"], + ), + cause=e, + ) + + self._copy_metadata_entry( + source_data["path"], + source_data["name"], + destination_data["path"], + destination_data["name"], + delete_source=True, + ) + self._set_display_metadata(destination_data, source_data=source_data) + + return self.path_in_storage(destination_data["fullpath"]) + + def has_analysis(self, path): + metadata = self.get_metadata(path) + return "analysis" in metadata + + def get_metadata(self, path): + path, name = self.sanitize(path) + return self._get_metadata_entry(path, name) + + def get_link(self, path, rel): + path, name = self.sanitize(path) + return self._get_links(name, path, rel) + + def add_link(self, path, rel, data): + path, name = self.sanitize(path) + self._add_links(name, path, [(rel, data)]) + + def remove_link(self, path, rel, data): + path, name = self.sanitize(path) + self._remove_links(name, path, [(rel, data)]) + + def add_history(self, path, data): + path, name = self.sanitize(path) + self._add_history(name, path, data) + + def update_history(self, path, index, data): + path, name = self.sanitize(path) + self._update_history(name, path, index, data) + + def remove_history(self, path, index): + path, name = self.sanitize(path) + self._delete_history(name, path, index) + + def get_additional_metadata(self, path, key): + path, name = self.sanitize(path) + metadata = self._get_metadata(path) + + if name not in metadata: + return + + return metadata[name].get(key) + + def set_additional_metadata(self, path, key, data, overwrite=False, merge=False): + path, name = self.sanitize(path) + metadata = self._get_metadata(path) + metadata_dirty = False + + if name not in metadata: + return + + metadata = self._copied_metadata(metadata, name) + + if key not in metadata[name] or overwrite: + metadata[name][key] = data + metadata_dirty = True + elif ( + key in metadata[name] + and isinstance(metadata[name][key], dict) + and isinstance(data, dict) + and merge + ): + import octoprint.util + + metadata[name][key] = octoprint.util.dict_merge( + metadata[name][key], data, in_place=True + ) + metadata_dirty = True + + if metadata_dirty: + self._save_metadata(path, metadata) + + def remove_additional_metadata(self, path, key): + path, name = self.sanitize(path) + metadata = self._get_metadata(path) + + if name not in metadata: + return + + if key not in metadata[name]: + return + + metadata = self._copied_metadata(metadata, name) + del metadata[name][key] + self._save_metadata(path, metadata) + + def split_path(self, path): + path = to_unicode(path) + split = path.split("/") + if len(split) == 1: + return "", split[0] + else: + return self.join_path(*split[:-1]), split[-1] + + def join_path(self, *path): + return "/".join(map(to_unicode, path)) + + def sanitize(self, path): + """ + Returns a ``(path, name)`` tuple derived from the provided ``path``. + + ``path`` may be: + * a storage path + * an absolute file system path + * a tuple or list containing all individual path elements + * a string representation of the path + * with or without a file name + + Note that for a ``path`` without a trailing slash the last part will be considered a file name and + hence be returned at second position. If you only need to convert a folder path, be sure to + include a trailing slash for a string ``path`` or an empty last element for a list ``path``. + """ + + path, name = self.canonicalize(path) + name = self.sanitize_name(name) + path = self.sanitize_path(path) + return path, name + + def canonicalize(self, path): + name = None + if isinstance(path, str): + path = to_unicode(path) + if path.startswith(self.basefolder): + path = path[len(self.basefolder) :] + path = path.replace(os.path.sep, "/") + path = path.split("/") + if isinstance(path, (list, tuple)): + if len(path) == 1: + name = to_unicode(path[0]) + path = "" + else: + name = to_unicode(path[-1]) + path = self.join_path(*map(to_unicode, path[:-1])) + if not path: + path = "" + + return path, name + + def sanitize_name(self, name): + """ + Raises a :class:`ValueError` for a ``name`` containing ``/`` or ``\\``. Otherwise + sanitizes the given ``name`` using ``octoprint.files.sanitize_filename``. Also + strips any leading ``.``. + """ + return sanitize_filename(name, really_universal=self._really_universal) + + def sanitize_path(self, path): + """ + Ensures that the on disk representation of ``path`` is located under the configured basefolder. Resolves all + relative path elements (e.g. ``..``) and sanitizes folder names using :func:`sanitize_name`. Final path is the + absolute path including leading ``basefolder`` path. + """ + path = to_unicode(path) + + if len(path): + if path[0] == "/": + path = path[1:] + elif path[0] == "." and path[1] == "/": + path = path[2:] + + path_elements = path.split("/") + joined_path = self.basefolder + for path_element in path_elements: + if path_element == ".." or path_element == ".": + joined_path = os.path.join(joined_path, path_element) + else: + joined_path = os.path.join(joined_path, self.sanitize_name(path_element)) + path = os.path.realpath(joined_path) + if not path.startswith(self.basefolder): + raise ValueError( + "path not contained in base folder: {path}".format(**locals()) + ) + return path + + def _sanitize_entry(self, entry, path, entry_path): + entry = to_unicode(entry) + sanitized = self.sanitize_name(entry) + if sanitized != entry: + # entry is not sanitized yet, let's take care of that + sanitized_path = os.path.join(path, sanitized) + sanitized_name, sanitized_ext = os.path.splitext(sanitized) + + counter = 1 + while os.path.exists(sanitized_path): + counter += 1 + sanitized = self.sanitize_name( + "{}_({}){}".format(sanitized_name, counter, sanitized_ext) + ) + sanitized_path = os.path.join(path, sanitized) + + try: + shutil.move(entry_path, sanitized_path) + + self._logger.info( + 'Sanitized "{}" to "{}"'.format(entry_path, sanitized_path) + ) + return sanitized, sanitized_path + except Exception: + self._logger.exception( + 'Error while trying to rename "{}" to "{}", ignoring file'.format( + entry_path, sanitized_path + ) + ) + raise + + return entry, entry_path + + def path_in_storage(self, path): + if isinstance(path, (tuple, list)): + path = self.join_path(*path) + if isinstance(path, str): + path = to_unicode(path) + if path.startswith(self.basefolder): + path = path[len(self.basefolder) :] + path = path.replace(os.path.sep, "/") + if path.startswith("/"): + path = path[1:] + + return path + + def path_on_disk(self, path): + path, name = self.sanitize(path) + return os.path.join(path, name) + + ##~~ internals + + def _add_history(self, name, path, data): + metadata = self._copied_metadata(self._get_metadata(path), name) + + if "hash" not in metadata[name]: + metadata[name]["hash"] = self._create_hash(os.path.join(path, name)) + + if "history" not in metadata[name]: + metadata[name]["history"] = [] + + metadata[name]["history"].append(data) + self._calculate_stats_from_history(name, path, metadata=metadata, save=False) + self._save_metadata(path, metadata) + + def _update_history(self, name, path, index, data): + metadata = self._get_metadata(path) + + if name not in metadata or "history" not in metadata[name]: + return + + metadata = self._copied_metadata(metadata, name) + + try: + metadata[name]["history"][index].update(data) + self._calculate_stats_from_history(name, path, metadata=metadata, save=False) + self._save_metadata(path, metadata) + except IndexError: + pass + + def _delete_history(self, name, path, index): + metadata = self._get_metadata(path) + + if name not in metadata or "history" not in metadata[name]: + return + + metadata = self._copied_metadata(metadata, name) + + try: + del metadata[name]["history"][index] + self._calculate_stats_from_history(name, path, metadata=metadata, save=False) + self._save_metadata(path, metadata) + except IndexError: + pass + + def _calculate_stats_from_history(self, name, path, metadata=None, save=True): + if metadata is None: + metadata = self._copied_metadata(self._get_metadata(path), name) + + if "history" not in metadata[name]: + return + + # collect data from history + former_print_times = {} + last_print = {} + + for history_entry in metadata[name]["history"]: + if ( + "printTime" not in history_entry + or "success" not in history_entry + or not history_entry["success"] + or "printerProfile" not in history_entry + ): + continue + + printer_profile = history_entry["printerProfile"] + if not printer_profile: + continue + + print_time = history_entry["printTime"] + try: + print_time = float(print_time) + except Exception: + self._logger.warning( + "Invalid print time value found in print history for {} in {}/.metadata.json: {!r}".format( + name, path, print_time + ) + ) + continue + + if printer_profile not in former_print_times: + former_print_times[printer_profile] = [] + former_print_times[printer_profile].append(print_time) + + if ( + printer_profile not in last_print + or last_print[printer_profile] is None + or ( + "timestamp" in history_entry + and history_entry["timestamp"] + > last_print[printer_profile]["timestamp"] + ) + ): + last_print[printer_profile] = history_entry + + # calculate stats + statistics = {"averagePrintTime": {}, "lastPrintTime": {}} + + for printer_profile in former_print_times: + if not former_print_times[printer_profile]: + continue + statistics["averagePrintTime"][printer_profile] = sum( + former_print_times[printer_profile] + ) / len(former_print_times[printer_profile]) + + for printer_profile in last_print: + if not last_print[printer_profile]: + continue + statistics["lastPrintTime"][printer_profile] = last_print[printer_profile][ + "printTime" + ] + + metadata[name]["statistics"] = statistics + + if save: + self._save_metadata(path, metadata) + + def _get_links(self, name, path, searched_rel): + metadata = self._get_metadata(path) + result = [] + + if name not in metadata: + return result + + if "links" not in metadata[name]: + return result + + for data in metadata[name]["links"]: + if "rel" not in data or not data["rel"] == searched_rel: + continue + result.append(data) + return result + + def _add_links(self, name, path, links): + file_type = octoprint.filemanager.get_file_type(name) + if file_type: + file_type = file_type[0] + + metadata = self._copied_metadata(self._get_metadata(path), name) + metadata_dirty = False + + if "hash" not in metadata[name]: + metadata[name]["hash"] = self._create_hash(os.path.join(path, name)) + + if "links" not in metadata[name]: + metadata[name]["links"] = [] + + for rel, data in links: + if (rel == "model" or rel == "machinecode") and "name" in data: + if file_type == "model" and rel == "model": + # adding a model link to a model doesn't make sense + return + elif file_type == "machinecode" and rel == "machinecode": + # adding a machinecode link to a machinecode doesn't make sense + return + + ref_path = os.path.join(path, data["name"]) + if not os.path.exists(ref_path): + # file doesn't exist, we won't create the link + continue + + # fetch hash of target file + if data["name"] in metadata and "hash" in metadata[data["name"]]: + hash = metadata[data["name"]]["hash"] + else: + hash = self._create_hash(ref_path) + if data["name"] not in metadata: + metadata[data["name"]] = {"hash": hash, "links": []} + else: + metadata[data["name"]]["hash"] = hash + + if "hash" in data and not data["hash"] == hash: + # file doesn't have the correct hash, we won't create the link + continue + + if "links" not in metadata[data["name"]]: + metadata[data["name"]]["links"] = [] + + # add reverse link to link target file + metadata[data["name"]]["links"].append( + { + "rel": "machinecode" if rel == "model" else "model", + "name": name, + "hash": metadata[name]["hash"], + } + ) + metadata_dirty = True + + link_dict = {"rel": rel, "name": data["name"], "hash": hash} + + elif rel == "web" and "href" in data: + link_dict = {"rel": rel, "href": data["href"]} + if "retrieved" in data: + link_dict["retrieved"] = data["retrieved"] + + else: + continue + + if link_dict: + metadata[name]["links"].append(link_dict) + metadata_dirty = True + + if metadata_dirty: + self._save_metadata(path, metadata) + + def _remove_links(self, name, path, links): + metadata = self._copied_metadata(self._get_metadata(path), name) + metadata_dirty = False + + hash = metadata[name].get("hash", self._create_hash(os.path.join(path, name))) + + for rel, data in links: + if (rel == "model" or rel == "machinecode") and "name" in data: + if data["name"] in metadata and "links" in metadata[data["name"]]: + ref_rel = "model" if rel == "machinecode" else "machinecode" + for link in metadata[data["name"]]["links"]: + if ( + link["rel"] == ref_rel + and "name" in link + and link["name"] == name + and "hash" in link + and link["hash"] == hash + ): + metadata[data["name"]] = copy.deepcopy(metadata[data["name"]]) + metadata[data["name"]]["links"].remove(link) + metadata_dirty = True + + if "links" in metadata[name]: + for link in metadata[name]["links"]: + if not link["rel"] == rel: + continue + + matches = True + for k, v in data.items(): + if k not in link or not link[k] == v: + matches = False + break + + if not matches: + continue + + metadata[name]["links"].remove(link) + metadata_dirty = True + + if metadata_dirty: + self._save_metadata(path, metadata) + + @time_this( + logtarget=__name__ + ".timings", + message="{func}({func_args},{func_kwargs}) took {timing:.2f}ms", + incl_func_args=True, + log_enter=True, + ) + def _list_folder(self, path, base="", force_refresh=False, **kwargs): + def get_size(nodes): + total_size = 0 + for node in nodes.values(): + if "size" in node: + total_size += node["size"] + return total_size + + def enrich_folders(nodes): + nodes = copy.copy(nodes) + for key, value in nodes.items(): + if value["type"] == "folder": + value = copy.copy(value) + value["children"] = self._list_folder( + os.path.join(path, key), + base=value["path"] + "/", + force_refresh=force_refresh, + ) + value["size"] = get_size(value["children"]) + nodes[key] = value + return nodes + + metadata_dirty = False + try: + with self._filelist_cache_mutex: + cache = self._filelist_cache.get(path) + lm = self.last_modified(path, recursive=True) + if not force_refresh and cache and cache[0] >= lm: + return enrich_folders(cache[1]) + + metadata = self._get_metadata(path) + if not metadata: + metadata = {} + + result = {} + + for entry in scandir(path): + if is_hidden_path(entry.name): + # no hidden files and folders + continue + + try: + entry_name = entry_display = entry.name + entry_path = entry.path + entry_is_file = entry.is_file() + entry_is_dir = entry.is_dir() + entry_stat = entry.stat() + except Exception: + # error while trying to fetch file metadata, that might be thanks to file already having + # been moved or deleted - ignore it and continue + continue + + try: + new_entry_name, new_entry_path = self._sanitize_entry( + entry_name, path, entry_path + ) + if entry_name != new_entry_name or entry_path != new_entry_path: + entry_display = to_unicode(entry_name) + entry_name = new_entry_name + entry_path = new_entry_path + entry_stat = os.stat(entry_path) + except Exception: + # error while trying to rename the file, we'll continue here and ignore it + continue + + path_in_location = entry_name if not base else base + entry_name + + try: + # file handling + if entry_is_file: + type_path = octoprint.filemanager.get_file_type(entry_name) + if not type_path: + # only supported extensions + continue + else: + file_type = type_path[0] + + if entry_name in metadata and isinstance( + metadata[entry_name], dict + ): + entry_metadata = metadata[entry_name] + if ( + "display" not in entry_metadata + and entry_display != entry_name + ): + if not metadata_dirty: + metadata = self._copied_metadata( + metadata, entry_name + ) + metadata[entry_name]["display"] = entry_display + entry_metadata["display"] = entry_display + metadata_dirty = True + else: + if not metadata_dirty: + metadata = self._copied_metadata(metadata, entry_name) + entry_metadata = self._add_basic_metadata( + path, + entry_name, + display_name=entry_display, + save=False, + metadata=metadata, + ) + metadata_dirty = True + + extended_entry_data = {} + extended_entry_data.update(entry_metadata) + extended_entry_data["name"] = entry_name + extended_entry_data["display"] = entry_metadata.get( + "display", entry_name + ) + extended_entry_data["path"] = path_in_location + extended_entry_data["type"] = file_type + extended_entry_data["typePath"] = type_path + stat = entry_stat + if stat: + extended_entry_data["size"] = stat.st_size + extended_entry_data["date"] = int(stat.st_mtime) + + result[entry_name] = extended_entry_data + + # folder recursion + elif entry_is_dir: + if entry_name in metadata and isinstance( + metadata[entry_name], dict + ): + entry_metadata = metadata[entry_name] + if ( + "display" not in entry_metadata + and entry_display != entry_name + ): + if not metadata_dirty: + metadata = self._copied_metadata( + metadata, entry_name + ) + metadata[entry_name]["display"] = entry_display + entry_metadata["display"] = entry_display + metadata_dirty = True + elif entry_name != entry_display: + if not metadata_dirty: + metadata = self._copied_metadata(metadata, entry_name) + entry_metadata = self._add_basic_metadata( + path, + entry_name, + display_name=entry_display, + save=False, + metadata=metadata, + ) + metadata_dirty = True + else: + entry_metadata = {} + + entry_data = { + "name": entry_name, + "display": entry_metadata.get("display", entry_name), + "path": path_in_location, + "type": "folder", + "typePath": ["folder"], + } + + result[entry_name] = entry_data + except Exception: + # So something went wrong somewhere while processing this file entry - log that and continue + self._logger.exception( + "Error while processing entry {}".format(entry_path) + ) + continue + + self._filelist_cache[path] = ( + lm, + result, + ) + return enrich_folders(result) + finally: + # save metadata + if metadata_dirty: + self._save_metadata(path, metadata) + + def _add_basic_metadata( + self, + path, + entry, + display_name=None, + additional_metadata=None, + save=True, + metadata=None, + ): + if additional_metadata is None: + additional_metadata = {} + + if metadata is None: + metadata = self._get_metadata(path) + + entry_path = os.path.join(path, entry) + + if os.path.isfile(entry_path): + entry_data = { + "hash": self._create_hash(os.path.join(path, entry)), + "links": [], + "notes": [], + } + if ( + path == self.basefolder + and self._old_metadata is not None + and entry in self._old_metadata + and "gcodeAnalysis" in self._old_metadata[entry] + ): + # if there is still old metadata available and that contains an analysis for this file, use it! + entry_data["analysis"] = self._old_metadata[entry]["gcodeAnalysis"] + + elif os.path.isdir(entry_path): + entry_data = {} + + else: + return + + if display_name is not None and not display_name == entry: + entry_data["display"] = display_name + + entry_data.update(additional_metadata) + + metadata = copy.copy(metadata) + metadata[entry] = entry_data + + if save: + self._save_metadata(path, metadata) + + return entry_data + + def _create_hash(self, path): + import hashlib + + blocksize = 65536 + hash = hashlib.sha1() + with io.open(path, "rb") as f: + buffer = f.read(blocksize) + while len(buffer) > 0: + hash.update(buffer) + buffer = f.read(blocksize) + + return hash.hexdigest() + + def _get_metadata_entry(self, path, name, default=None): + with self._get_metadata_lock(path): + metadata = self._get_metadata(path) + return metadata.get(name, default) + + def _remove_metadata_entry(self, path, name): + with self._get_metadata_lock(path): + metadata = self._get_metadata(path) + if name not in metadata: + return + + metadata = copy.copy(metadata) + + if "hash" in metadata[name]: + hash = metadata[name]["hash"] + for m in metadata.values(): + if "links" not in m: + continue + links_hash = ( + lambda link: "hash" in link + and link["hash"] == hash + and "rel" in link + and (link["rel"] == "model" or link["rel"] == "machinecode") + ) + m["links"] = [link for link in m["links"] if not links_hash(link)] + + del metadata[name] + self._save_metadata(path, metadata) + + def _update_metadata_entry(self, path, name, data): + with self._get_metadata_lock(path): + metadata = copy.copy(self._get_metadata(path)) + metadata[name] = data + self._save_metadata(path, metadata) + + def _copy_metadata_entry( + self, + source_path, + source_name, + destination_path, + destination_name, + delete_source=False, + updates=None, + ): + with self._get_metadata_lock(source_path): + source_data = self._get_metadata_entry(source_path, source_name, default={}) + if not source_data: + return + + if delete_source: + self._remove_metadata_entry(source_path, source_name) + + if updates is not None: + source_data.update(updates) + + with self._get_metadata_lock(destination_path): + self._update_metadata_entry(destination_path, destination_name, source_data) + + def _get_metadata(self, path, force=False): + import json + + if not force: + metadata = self._metadata_cache.get(path) + if metadata: + return metadata + + self._migrate_metadata(path) + + metadata_path = os.path.join(path, ".metadata.json") + + metadata = None + with self._get_persisted_metadata_lock(path): + if os.path.exists(metadata_path): + with io.open(metadata_path, "rt", encoding="utf-8") as f: + try: + metadata = json.load(f) + except Exception: + self._logger.exception( + "Error while reading .metadata.json from {path}".format( + **locals() + ) + ) + + def valid_json(value): + try: + json.dumps(value, allow_nan=False) + return True + except Exception: + return False + + if isinstance(metadata, dict): + old_size = len(metadata) + metadata = {k: v for k, v in metadata.items() if valid_json(v)} + metadata = { + k: v for k, v in metadata.items() if os.path.exists(os.path.join(path, k)) + } + new_size = len(metadata) + if new_size != old_size: + self._logger.info( + "Deleted {} stale or invalid entries from metadata for path {}".format( + old_size - new_size, path + ) + ) + self._save_metadata(path, metadata) + else: + with self._get_metadata_lock(path): + self._metadata_cache[path] = metadata + return metadata + else: + return {} + + def _save_metadata(self, path, metadata): + import json + + with self._get_metadata_lock(path): + self._metadata_cache[path] = metadata + + with self._get_persisted_metadata_lock(path): + metadata_path = os.path.join(path, ".metadata.json") + try: + with atomic_write(metadata_path, mode="wb") as f: + f.write( + to_bytes(json.dumps(metadata, indent=2, separators=(",", ": "))) + ) + except Exception: + self._logger.exception( + "Error while writing .metadata.json to {path}".format(**locals()) + ) + + def _delete_metadata(self, path): + with self._get_metadata_lock(path): + if path in self._metadata_cache: + del self._metadata_cache[path] + + with self._get_persisted_metadata_lock(path): + metadata_files = (".metadata.json", ".metadata.yaml") + for metadata_file in metadata_files: + metadata_path = os.path.join(path, metadata_file) + if os.path.exists(metadata_path): + try: + os.remove(metadata_path) + except Exception: + self._logger.exception( + "Error while deleting {metadata_file} from {path}".format( + **locals() + ) + ) + + @staticmethod + def _copied_metadata(metadata, name): + metadata = copy.copy(metadata) + metadata[name] = copy.deepcopy(metadata.get(name, {})) + return metadata + + def _migrate_metadata(self, path): + # we switched to json in 1.3.9 - if we still have yaml here, migrate it now + import json + + import yaml + + with self._get_persisted_metadata_lock(path): + metadata_path_yaml = os.path.join(path, ".metadata.yaml") + metadata_path_json = os.path.join(path, ".metadata.json") + + if not os.path.exists(metadata_path_yaml): + # nothing to migrate + return + + if os.path.exists(metadata_path_json): + # already migrated + try: + os.remove(metadata_path_yaml) + except Exception: + self._logger.exception( + "Error while removing .metadata.yaml from {path}".format( + **locals() + ) + ) + return + + with io.open(metadata_path_yaml, "rt", encoding="utf-8") as f: + try: + metadata = yaml.safe_load(f) + except Exception: + self._logger.exception( + "Error while reading .metadata.yaml from {path}".format( + **locals() + ) + ) + return + + if not isinstance(metadata, dict): + # looks invalid, ignore it + return + + with atomic_write(metadata_path_json, mode="wb") as f: + f.write(to_bytes(json.dumps(metadata, indent=2, separators=(",", ": ")))) + + try: + os.remove(metadata_path_yaml) + except Exception: + self._logger.exception( + "Error while removing .metadata.yaml from {path}".format(**locals()) + ) + + @contextmanager + def _get_metadata_lock(self, path): + with self._metadata_lock_mutex: + if path not in self._metadata_locks: + import threading + + self._metadata_locks[path] = (0, threading.RLock()) + + counter, lock = self._metadata_locks[path] + counter += 1 + self._metadata_locks[path] = (counter, lock) + + yield lock + + with self._metadata_lock_mutex: + counter = self._metadata_locks[path][0] + counter -= 1 + if counter <= 0: + del self._metadata_locks[path] + else: + self._metadata_locks[path] = (counter, lock) + + @contextmanager + def _get_persisted_metadata_lock(self, path): + with self._persisted_metadata_lock_mutex: + if path not in self._persisted_metadata_locks: + import threading + + self._persisted_metadata_locks[path] = (0, threading.RLock()) + + counter, lock = self._persisted_metadata_locks[path] + counter += 1 + self._persisted_metadata_locks[path] = (counter, lock) + + yield lock + + with self._persisted_metadata_lock_mutex: + counter = self._persisted_metadata_locks[path][0] + counter -= 1 + if counter <= 0: + del self._persisted_metadata_locks[path] + else: + self._persisted_metadata_locks[path] = (counter, lock) diff --git a/src/octoprint/filemanager/util.py b/src/octoprint/filemanager/util.py index d8bd9561f6..252ed9089c 100644 --- a/src/octoprint/filemanager/util.py +++ b/src/octoprint/filemanager/util.py @@ -1,241 +1,263 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals __author__ = "Gina Häußge " -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" import io +import os +from octoprint import UMASK from octoprint.util import atomic_write + class AbstractFileWrapper(object): - """ - Wrapper for file representations to save to storages. + """ + Wrapper for file representations to save to storages. + + Arguments: + filename (str): The file's name + """ + + DEFAULT_PERMISSIONS = 0o664 - Arguments: - filename (str): The file's name - """ + def __init__(self, filename): + self.filename = filename - def __init__(self, filename): - self.filename = filename + def save(self, path, permissions=None): + """ + Saves the file's content to the given absolute path. - def save(self, path): - """ - Saves the file's content to the given absolute path. + Arguments: + path (str): The absolute path to where to save the file + permissions (int): The permissions to set on the file + """ + raise NotImplementedError() - Arguments: - path (str): The absolute path to where to save the file - """ - raise NotImplementedError() + def stream(self): + """ + Returns a Python stream object (subclass of io.IOBase) representing the file's contents. - def stream(self): - """ - Returns a Python stream object (subclass of io.IOBase) representing the file's contents. + Returns: + io.IOBase: The file's contents as a stream. + """ + raise NotImplementedError() - Returns: - io.IOBase: The file's contents as a stream. - """ - raise NotImplementedError() class DiskFileWrapper(AbstractFileWrapper): - """ - An implementation of :class:`.AbstractFileWrapper` that wraps an actual file on disk. The `save` implementations - will either copy the file to the new path (preserving file attributes) or -- if `move` is `True` (the default) -- - move the file. + """ + An implementation of :class:`.AbstractFileWrapper` that wraps an actual file on disk. The `save` implementations + will either copy the file to the new path (preserving file attributes) or -- if `move` is `True` (the default) -- + move the file. + + Arguments: + filename (str): The file's name + path (str): The file's absolute path + move (boolean): Whether to move the file upon saving (True, default) or copying. + """ + + def __init__(self, filename, path, move=True): + AbstractFileWrapper.__init__(self, filename) + self.path = path + self.move = move - Arguments: - filename (str): The file's name - path (str): The file's absolute path - move (boolean): Whether to move the file upon saving (True, default) or copying. - """ + def save(self, path, permissions=None): + import shutil - def __init__(self, filename, path, move=True): - AbstractFileWrapper.__init__(self, filename) - self.path = path - self.move = move + if self.move: + shutil.move(self.path, path) + else: + shutil.copy2(self.path, path) - def save(self, path): - import shutil + if permissions is None: + permissions = self.DEFAULT_PERMISSIONS & ~UMASK + os.chmod(path, permissions) - if self.move: - shutil.move(self.path, path) - else: - shutil.copy2(self.path, path) + def stream(self): + return io.open(self.path, "rb") - def stream(self): - return io.open(self.path, "rb") class StreamWrapper(AbstractFileWrapper): - """ - A wrapper allowing processing of one or more consecutive streams. - - Arguments: - *streams (io.IOBase): One or more streams to process one after another to save to storage. - """ - def __init__(self, filename, *streams): - if not len(streams) > 0: - raise ValueError("Need at least one stream to wrap") - - AbstractFileWrapper.__init__(self, filename) - self.streams = streams - - def save(self, path): - """ - Will dump the contents of all streams provided during construction into the target file, in the order they were - provided. - """ - import shutil - - with atomic_write(path, "wb") as dest: - with self.stream() as source: - shutil.copyfileobj(source, dest) - - def stream(self): - """ - If more than one stream was provided to the constructor, will return a :class:`.MultiStream` wrapping all - provided streams in the order they were provided, else the first and only stream is returned directly. - """ - if len(self.streams) > 1: - return MultiStream(*self.streams) - else: - return self.streams[0] + """ + A wrapper allowing processing of one or more consecutive streams. + + Arguments: + *streams: One or more :py:class:`io.IOBase` streams to process one after another to save to storage. + """ + + def __init__(self, filename, *streams): + if not len(streams) > 0: + raise ValueError("Need at least one stream to wrap") + + AbstractFileWrapper.__init__(self, filename) + self.streams = streams + + def save(self, path, permissions=None): + """ + Will dump the contents of all streams provided during construction into the target file, in the order they were + provided. + """ + import shutil + + with atomic_write(path, mode="wb") as dest: + for source in self.streams: + shutil.copyfileobj(source, dest) + if permissions is None: + permissions = self.DEFAULT_PERMISSIONS & ~UMASK + os.chmod(path, permissions) + + def stream(self): + """ + If more than one stream was provided to the constructor, will return a :class:`.MultiStream` wrapping all + provided streams in the order they were provided, else the first and only stream is returned directly. + """ + if len(self.streams) > 1: + return MultiStream(*self.streams) + else: + return self.streams[0] + class MultiStream(io.RawIOBase): - """ - A stream implementation which when read reads from multiple streams, one after the other, basically concatenating - their contents in the order they are provided to the constructor. - - Arguments: - *streams (io.IOBase): One or more streams to concatenate. - """ - def __init__(self, *streams): - io.RawIOBase.__init__(self) - self.streams = streams - self.current_stream = 0 - - def read(self, n=-1): - if n == 0: - return b'' - - if len(self.streams) == 0: - return b'' - - while self.current_stream < len(self.streams): - stream = self.streams[self.current_stream] - - result = stream.read(n) - if result is None or len(result) != 0: - return result - else: - self.current_stream += 1 - - return b'' - - def readinto(self, b): - n = len(b) - read = self.read(n) - b[:len(read)] = read - return len(read) - - def close(self): - for stream in self.streams: - try: - stream.close() - except: - pass - - def readable(self, *args, **kwargs): - return True - - def seekable(self, *args, **kwargs): - return False - - def writable(self, *args, **kwargs): - return False + """ + A stream implementation which when read reads from multiple streams, one after the other, basically concatenating + their contents in the order they are provided to the constructor. + + Arguments: + *streams: One or more :py:class:`io.IOBase` streams to concatenate. + """ + + def __init__(self, *streams): + io.RawIOBase.__init__(self) + self.streams = streams + self.current_stream = 0 + + def read(self, n=-1): + if n == 0: + return b"" + + if len(self.streams) == 0: + return b"" + + while self.current_stream < len(self.streams): + stream = self.streams[self.current_stream] + + result = stream.read(n) + if result is None or len(result) != 0: + return result + else: + self.current_stream += 1 + + return b"" + + def readinto(self, b): + n = len(b) + read = self.read(n) + b[: len(read)] = read + return len(read) + + def close(self): + for stream in self.streams: + try: + stream.close() + except Exception: + pass + + def readable(self, *args, **kwargs): + return True + + def seekable(self, *args, **kwargs): + return False + + def writable(self, *args, **kwargs): + return False + class LineProcessorStream(io.RawIOBase): - """ - While reading from this stream the provided `input_stream` is read line by line, calling the (overridable) method - :meth:`.process_line` for each read line. - - Sub classes can thus modify the contents of the `input_stream` in line, while it is being read. - - Arguments: - input_stream (io.IOBase): The stream to process on the fly. - """ - - def __init__(self, input_stream): - io.RawIOBase.__init__(self) - self.input_stream = io.BufferedReader(input_stream) - self.leftover = None - - def read(self, n=-1): - if n == 0: - return b'' - - result = b'' - while len(result) < n or n == -1: - bytes_left = (n - len(result)) if n != -1 else -1 - if self.leftover is not None: - if bytes_left != -1 and bytes_left < len(self.leftover): - result += self.leftover[:bytes_left] - self.leftover = self.leftover[bytes_left:] - break - else: - result += self.leftover - self.leftover = None - - processed_line = None - while processed_line is None: - line = self.input_stream.readline() - if not line: - break - processed_line = self.process_line(line) - - if processed_line is None: - break - - bytes_left = (n - len(result)) if n != -1 else -1 - if bytes_left != -1 and bytes_left < len(processed_line): - result += processed_line[:bytes_left] - self.leftover = processed_line[bytes_left:] - break - else: - result += processed_line - - return result - - def readinto(self, b): - n = len(b) - read = self.read(n) - b[:len(read)] = read - return len(read) - - def process_line(self, line): - """ - Called from the `read` Method of this stream with each line read from `self.input_stream`. - - By returning ``None`` the line will not be returned from the read stream, effectively being stripped from the - wrapper `input_stream`. - - Arguments: - line (str): The line as read from `self.input_stream` - - Returns: - str or None: The processed version of the line (might also be multiple lines), or None if the line is to be - stripped from the processed stream. - """ - return line - - def close(self): - self.input_stream.close() - - def readable(self, *args, **kwargs): - return True - - def seekable(self, *args, **kwargs): - return False - - def writable(self, *args, **kwargs): - return False + """ + While reading from this stream the provided `input_stream` is read line by line, calling the (overridable) method + :meth:`.process_line` for each read line. + + Sub classes can thus modify the contents of the `input_stream` in line, while it is being read. Keep in mind that + ``process_line`` will receive the line as a byte stream - if underlying code needs to operate on unicode you'll need + to do the decoding yourself. + + Arguments: + input_stream (io.RawIOBase): The stream to process on the fly. + """ + + def __init__(self, input_stream): + io.RawIOBase.__init__(self) + self.input_stream = io.BufferedReader(input_stream) + self.leftover = bytearray() + + def read(self, n=-1): + if n == 0: + return b"" + + result = bytearray() + while len(result) < n or n == -1: + # add left over from previous loop + bytes_left = (n - len(result)) if n != -1 else -1 + if bytes_left != -1 and bytes_left < len(self.leftover): + # only s + result += self.leftover[:bytes_left] + self.leftover = self.leftover[bytes_left:] + break + else: + result += self.leftover + self.leftover = bytearray() + + # read one line from the underlying stream + processed_line = None + while processed_line is None: + line = self.input_stream.readline() + if not line: + break + processed_line = self.process_line(line) + if processed_line is None: + break + + bytes_left = (n - len(result)) if n != -1 else -1 + if bytes_left != -1 and bytes_left < len(processed_line): + result += processed_line[:bytes_left] + self.leftover = processed_line[bytes_left:] + break + else: + result += processed_line + + return bytes(result) + + def readinto(self, b): + n = len(b) + read = self.read(n) + b[: len(read)] = read + return len(read) + + def process_line(self, line): + """ + Called from the `read` Method of this stream with each line read from `self.input_stream`. + + By returning ``None`` the line will not be returned from the read stream, effectively being stripped from the + wrapper `input_stream`. + + Arguments: + line (bytes): The line as read from `self.input_stream` in byte representation (str under Python 2 and + bytes under Python 3) + + Returns: + bytes or None: The processed version of the line (might also be multiple lines), or None if the line is to be + stripped from the processed stream. + """ + return line + + def close(self): + self.input_stream.close() + + def readable(self, *args, **kwargs): + return True + + def seekable(self, *args, **kwargs): + return False + + def writable(self, *args, **kwargs): + return False diff --git a/src/octoprint/logging/__init__.py b/src/octoprint/logging/__init__.py index 1021b169aa..10ac10177d 100644 --- a/src/octoprint/logging/__init__.py +++ b/src/octoprint/logging/__init__.py @@ -1,150 +1,165 @@ -# coding=utf-8 -from __future__ import absolute_import +from __future__ import absolute_import, division, print_function, unicode_literals + +from typing import Union + + +from octoprint.logging import filters # noqa: F401 +from octoprint.logging import handlers # noqa: F401 -from octoprint.logging import handlers def log_to_handler(logger, handler, level, msg, exc_info=None, extra=None, *args): - """ - Logs to the provided handler only. - - Arguments: - logger: logger to log to - handler: handler to restrict logging to - level: level to log at - msg: message to log - exc_info: optional exception info - extra: optional extra data - *args: log args - """ - from logging import _srcfile - import sys - - # this is just the same as logging.Logger._log - - if _srcfile: - # IronPython doesn't track Python frames, so findCaller raises an - # exception on some versions of IronPython. We trap it here so that - # IronPython can use logging. - try: - fn, lno, func = logger.findCaller() - except ValueError: - fn, lno, func = "(unknown file)", 0, "(unknown function)" - else: - fn, lno, func = "(unknown file)", 0, "(unknown function)" - if exc_info: - if not isinstance(exc_info, tuple): - exc_info = sys.exc_info() - - record = logger.makeRecord(logger.name, level, fn, lno, msg, args, exc_info, func, extra) - - # and this is a mixture of logging.Logger.handle and logging.Logger.callHandlers - - if (not logger.disabled) and logger.filter(record): - if record.levelno >= handler.level: - handler.handle(record) + """ + Logs to the provided handler only. + + Arguments: + logger: logger to log to + handler: handler to restrict logging to + level: level to log at + msg: message to log + exc_info: optional exception info + extra: optional extra data + *args: log args + """ + import sys + + try: + from logging import _srcfile + except ImportError: + _srcfile = None + + # this is just the same as logging.Logger._log + + if _srcfile: + # IronPython doesn't track Python frames, so findCaller raises an + # exception on some versions of IronPython. We trap it here so that + # IronPython can use logging. + try: + fn, lno, func = logger.findCaller() + except ValueError: + fn, lno, func = "(unknown file)", 0, "(unknown function)" + else: + fn, lno, func = "(unknown file)", 0, "(unknown function)" + if exc_info: + if not isinstance(exc_info, tuple): + exc_info = sys.exc_info() + + record = logger.makeRecord( + logger.name, level, fn, lno, msg, args, exc_info, func, extra + ) + + # and this is a mixture of logging.Logger.handle and logging.Logger.callHandlers + + if (not logger.disabled) and logger.filter(record): + if record.levelno >= handler.level: + handler.handle(record) def get_handler(name, logger=None): - """ - Retrieves the handler named ``name``. + """ + Retrieves the handler named ``name``. - If optional ``logger`` is provided, search will be - limited to that logger, otherwise the root logger will be - searched. + If optional ``logger`` is provided, search will be + limited to that logger, otherwise the root logger will be + searched. - Arguments: - name: the name of the handler to look for - logger: (optional) the logger to search in, root logger if not provided + Arguments: + name: the name of the handler to look for + logger: (optional) the logger to search in, root logger if not provided - Returns: - the handler if it could be found, None otherwise - """ - import logging + Returns: + the handler if it could be found, None otherwise + """ + import logging - if logger is None: - logger = logging.getLogger() + if logger is None: + logger = logging.getLogger() - for handler in logger.handlers: - if handler.get_name() == name: - return handler + for handler in logger.handlers: + if handler.get_name() == name: + return handler - return None + return None def get_divider_line(c, message=None, length=78, indent=3): - """ - Generate a divider line for logging, optionally with included message. - - Examples: - - >>> get_divider_line("-") - '------------------------------------------------------------------------------' - >>> get_divider_line("=", length=10) - '==========' - >>> get_divider_line("-", message="Hi", length=10) - '--- Hi ---' - >>> get_divider_line("-", message="A slightly longer text") - '--- A slightly longer text ---------------------------------------------------' - >>> get_divider_line("-", message="A slightly longer text", indent=5) - '----- A slightly longer text -------------------------------------------------' - >>> get_divider_line("-", message="Hello World!", length=10) - '--- Hello World!' - >>> get_divider_line(None) - Traceback (most recent call last): - ... - AssertionError: c is not text - >>> get_divider_line("´`") - Traceback (most recent call last): - ... - AssertionError: c is not a single character - >>> get_divider_line("-", message=3) - Traceback (most recent call last): - ... - AssertionError: message is not text - >>> get_divider_line("-", length="hello") - Traceback (most recent call last): - ... - AssertionError: length is not an int - >>> get_divider_line("-", indent="hi") - Traceback (most recent call last): - ... - AssertionError: indent is not an int - - Arguments: - c: character to use for the line - message: message to print in the line - length: length of the line - indent: indentation of message in line - - Returns: - formatted divider line - """ - - assert isinstance(c, (str, unicode, bytes)), "c is not text" - assert len(c) == 1, "c is not a single character" - assert isinstance(length, int), "length is not an int" - assert isinstance(indent, int), "indent is not an int" - - if message is None: - return c * length - - assert isinstance(message, (str, unicode, bytes)), "message is not text" - - space = length - 2 * (indent + 1) - if space >= len(message): - return c * indent + " " + message + " " + c * (length - indent - 2 - len(message)) - else: - return c * indent + " " + message + """ + Generate a divider line for logging, optionally with included message. + + Examples: + + >>> get_divider_line("-") # doctest: +ALLOW_UNICODE + '------------------------------------------------------------------------------' + >>> get_divider_line("=", length=10) # doctest: +ALLOW_UNICODE + '==========' + >>> get_divider_line("-", message="Hi", length=10) # doctest: +ALLOW_UNICODE + '--- Hi ---' + >>> get_divider_line("-", message="A slightly longer text") # doctest: +ALLOW_UNICODE + '--- A slightly longer text ---------------------------------------------------' + >>> get_divider_line("-", message="A slightly longer text", indent=5) # doctest: +ALLOW_UNICODE + '----- A slightly longer text -------------------------------------------------' + >>> get_divider_line("-", message="Hello World!", length=10) # doctest: +ALLOW_UNICODE + '--- Hello World!' + >>> get_divider_line(None) + Traceback (most recent call last): + ... + AssertionError: c is not text + >>> get_divider_line("´`") + Traceback (most recent call last): + ... + AssertionError: c is not a single character + >>> get_divider_line("-", message=3) + Traceback (most recent call last): + ... + AssertionError: message is not text + >>> get_divider_line("-", length="hello") + Traceback (most recent call last): + ... + AssertionError: length is not an int + >>> get_divider_line("-", indent="hi") + Traceback (most recent call last): + ... + AssertionError: indent is not an int + + Arguments: + c: character to use for the line + message: message to print in the line + length: length of the line + indent: indentation of message in line + + Returns: + formatted divider line + """ + + assert isinstance(c, str), "c is not text" + assert len(c) == 1, "c is not a single character" + assert isinstance(length, int), "length is not an int" + assert isinstance(indent, int), "indent is not an int" + + if message is None: + return c * length + + assert isinstance(message, str), "message is not text" + + space = length - 2 * (indent + 1) + if space >= len(message): + return c * indent + " " + message + " " + c * (length - indent - 2 - len(message)) + else: + return c * indent + " " + message def prefix_multilines(text, prefix=": "): - lines = text.splitlines() - if not lines: - return "" + # type: (Union[str, bytes], str) -> str + from octoprint.util import to_unicode - if len(lines) == 1: - return lines[0] + lines = text.splitlines() + if not lines: + return "" - return lines[0] + "\n" + "\n".join(map(lambda line: prefix + line, - lines[1:])) + if len(lines) == 1: + return to_unicode(lines[0]) + return ( + to_unicode(lines[0]) + + "\n" + + "\n".join(map(lambda line: prefix + to_unicode(line), lines[1:])) + ) diff --git a/src/octoprint/logging/filters.py b/src/octoprint/logging/filters.py new file mode 100644 index 0000000000..2a0dfbd2fd --- /dev/null +++ b/src/octoprint/logging/filters.py @@ -0,0 +1,28 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +import logging + +try: + from urllib import parse as urlparse +except ImportError: + import urlparse + + +class TornadoAccessFilter(logging.Filter): + def filter(self, record): + try: + status, request_line, rtt = record.args + + if status == 409: + + method, url, client = request_line.split() + u = urlparse.urlparse(url) + if u.path in ("/api/printer",): + record.levelno = logging.INFO + record.levelname = logging.getLevelName(record.levelno) + except Exception: + logging.getLogger(__name__).exception( + "Error while filtering log record {!r}".format(record) + ) + + return logging.Filter.filter(self, record) diff --git a/src/octoprint/logging/handlers.py b/src/octoprint/logging/handlers.py index 232216ee06..95fa6998b3 100644 --- a/src/octoprint/logging/handlers.py +++ b/src/octoprint/logging/handlers.py @@ -1,151 +1,195 @@ -# coding=utf-8 -from __future__ import absolute_import +from __future__ import absolute_import, division, print_function, unicode_literals +# noinspection PyCompatibility +import concurrent.futures import logging.handlers import os import re import time + +class AsyncLogHandlerMixin(logging.Handler): + def __init__(self, *args, **kwargs): + self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + super(AsyncLogHandlerMixin, self).__init__(*args, **kwargs) + + def emit(self, record): + if getattr(self._executor, "_shutdown", False): + return + + try: + self._executor.submit(self._emit, record) + except Exception: + self.handleError(record) + + def _emit(self, record): + # noinspection PyUnresolvedReferences + super(AsyncLogHandlerMixin, self).emit(record) + + def close(self): + self._executor.shutdown(wait=True) + super(AsyncLogHandlerMixin, self).close() + + class CleaningTimedRotatingFileHandler(logging.handlers.TimedRotatingFileHandler): + def __init__(self, *args, **kwargs): + kwargs["encoding"] = kwargs.get("encoding", "utf-8") - def __init__(self, *args, **kwargs): - logging.handlers.TimedRotatingFileHandler.__init__(self, *args, **kwargs) - - # clean up old files on handler start - if self.backupCount > 0: - for s in self.getFilesToDelete(): - os.remove(s) - - -class OctoPrintLogHandler(CleaningTimedRotatingFileHandler): - rollover_callbacks = [] - - @classmethod - def registerRolloverCallback(cls, callback, *args, **kwargs): - cls.rollover_callbacks.append((callback, args, kwargs)) - - def doRollover(self): - CleaningTimedRotatingFileHandler.doRollover(self) - - for rcb in self.rollover_callbacks: - callback, args, kwargs = rcb - callback(*args, **kwargs) - - -class SerialLogHandler(logging.handlers.RotatingFileHandler): - - _do_rollover = False - _suffix_template = "%Y-%m-%d_%H-%M-%S" - _file_pattern = re.compile(r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$") - - @classmethod - def on_open_connection(cls): - cls._do_rollover = True - - def __init__(self, *args, **kwargs): - logging.handlers.RotatingFileHandler.__init__(self, *args, **kwargs) - self.cleanupFiles() - - def emit(self, record): - logging.handlers.RotatingFileHandler.emit(self, record) - - def shouldRollover(self, record): - return self.__class__._do_rollover - - def getFilesToDelete(self): - """ - Determine the files to delete when rolling over. - """ - dirName, baseName = os.path.split(self.baseFilename) - fileNames = os.listdir(dirName) - result = [] - prefix = baseName + "." - plen = len(prefix) - for fileName in fileNames: - if fileName[:plen] == prefix: - suffix = fileName[plen:] - if self.__class__._file_pattern.match(suffix): - result.append(os.path.join(dirName, fileName)) - result.sort() - if len(result) < self.backupCount: - result = [] - else: - result = result[:len(result) - self.backupCount] - return result - - def cleanupFiles(self): - if self.backupCount > 0: - for path in self.getFilesToDelete(): - os.remove(path) - - def doRollover(self): - self.__class__._do_rollover = False - - if self.stream: - self.stream.close() - self.stream = None - - if os.path.exists(self.baseFilename): - # figure out creation date/time to use for file suffix - t = time.localtime(os.stat(self.baseFilename).st_mtime) - dfn = self.baseFilename + "." + time.strftime(self.__class__._suffix_template, t) - if os.path.exists(dfn): - os.remove(dfn) - os.rename(self.baseFilename, dfn) - - self.cleanupFiles() - if not self.delay: - self.stream = self._open() + super(CleaningTimedRotatingFileHandler, self).__init__(*args, **kwargs) -class RecordingLogHandler(logging.Handler): - def __init__(self, target=None, level=logging.NOTSET): - logging.Handler.__init__(self, level=level) - self._buffer = [] - self._target = target + # clean up old files on handler start + if self.backupCount > 0: + for s in self.getFilesToDelete(): + os.remove(s) + + +class OctoPrintLogHandler(AsyncLogHandlerMixin, CleaningTimedRotatingFileHandler): + rollover_callbacks = [] + + def __init__(self, *args, **kwargs): + kwargs["encoding"] = kwargs.get("encoding", "utf-8") + super(OctoPrintLogHandler, self).__init__(*args, **kwargs) + + @classmethod + def registerRolloverCallback(cls, callback, *args, **kwargs): + cls.rollover_callbacks.append((callback, args, kwargs)) + + def doRollover(self): + super(OctoPrintLogHandler, self).doRollover() + + for rcb in self.rollover_callbacks: + callback, args, kwargs = rcb + callback(*args, **kwargs) - def emit(self, record): - self._buffer.append(record) - def setTarget(self, target): - self._target = target +class OctoPrintStreamHandler(AsyncLogHandlerMixin, logging.StreamHandler): + pass - def flush(self): - if not self._target: - return - self.acquire() - try: - for record in self._buffer: - self._target.handle(record) - self._buffer = [] - finally: - self.release() +class TriggeredRolloverLogHandler( + AsyncLogHandlerMixin, logging.handlers.RotatingFileHandler +): - def close(self): - self.flush() - self.acquire() - try: - self._buffer = [] - finally: - self.release() + do_rollover = False + suffix_template = "%Y-%m-%d_%H-%M-%S" + file_pattern = re.compile(r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$") - def __len__(self): - return len(self._buffer) + @classmethod + def arm_rollover(cls): + cls.do_rollover = True + def __init__(self, *args, **kwargs): + kwargs["encoding"] = kwargs.get("encoding", "utf-8") + super(TriggeredRolloverLogHandler, self).__init__(*args, **kwargs) + self.cleanupFiles() + def shouldRollover(self, record): + return self.do_rollover + + def getFilesToDelete(self): + """ + Determine the files to delete when rolling over. + """ + dirName, baseName = os.path.split(self.baseFilename) + fileNames = os.listdir(dirName) + result = [] + prefix = baseName + "." + plen = len(prefix) + for fileName in fileNames: + if fileName[:plen] == prefix: + suffix = fileName[plen:] + if type(self).file_pattern.match(suffix): + result.append(os.path.join(dirName, fileName)) + result.sort() + if len(result) < self.backupCount: + result = [] + else: + result = result[: len(result) - self.backupCount] + return result + + def cleanupFiles(self): + if self.backupCount > 0: + for path in self.getFilesToDelete(): + os.remove(path) + + def doRollover(self): + self.do_rollover = False + + if self.stream: + self.stream.close() + self.stream = None + + if os.path.exists(self.baseFilename): + # figure out creation date/time to use for file suffix + t = time.localtime(os.stat(self.baseFilename).st_mtime) + dfn = self.baseFilename + "." + time.strftime(type(self).suffix_template, t) + if os.path.exists(dfn): + os.remove(dfn) + os.rename(self.baseFilename, dfn) + + self.cleanupFiles() + if not self.delay: + self.stream = self._open() + + +class SerialLogHandler(TriggeredRolloverLogHandler): + pass + + +class PluginTimingsLogHandler(TriggeredRolloverLogHandler): + pass + + +class RecordingLogHandler(logging.Handler): + def __init__(self, target=None, *args, **kwargs): + super(RecordingLogHandler, self).__init__(*args, **kwargs) + self._buffer = [] + self._target = target + + def emit(self, record): + self._buffer.append(record) + + def setTarget(self, target): + self._target = target + + def flush(self): + if not self._target: + return + + self.acquire() + try: + for record in self._buffer: + self._target.handle(record) + self._buffer = [] + finally: + self.release() + + def close(self): + self.flush() + self.acquire() + try: + self._buffer = [] + finally: + self.release() + + def __len__(self): + return len(self._buffer) + + +# noinspection PyAbstractClass class CombinedLogHandler(logging.Handler): - def __init__(self, *handlers): - logging.Handler.__init__(self) - self._handlers = handlers - - def setHandlers(self, *handlers): - self._handlers = handlers - - def handle(self, record): - self.acquire() - try: - if self._handlers: - for handler in self._handlers: - handler.handle(record) - finally: - self.release() + def __init__(self, *handlers): + logging.Handler.__init__(self) + self._handlers = handlers + + def setHandlers(self, *handlers): + self._handlers = handlers + + def handle(self, record): + self.acquire() + try: + if self._handlers: + for handler in self._handlers: + handler.handle(record) + finally: + self.release() diff --git a/src/octoprint/plugin/__init__.py b/src/octoprint/plugin/__init__.py index fa24ccf0a8..b3c0303307 100644 --- a/src/octoprint/plugin/__init__.py +++ b/src/octoprint/plugin/__init__.py @@ -1,4 +1,3 @@ -# coding=utf-8 """ This module represents OctoPrint's plugin subsystem. This includes management and helper methods as well as the registered plugin types. @@ -12,544 +11,617 @@ .. autoclass:: PluginSettings :members: """ - -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals __author__ = "Gina Häußge " -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" -import os import logging +import os +from octoprint.plugin.core import Plugin, PluginInfo, PluginManager # noqa: F401 +from octoprint.plugin.types import * # noqa: F401,F403 ## used by multiple other modules +from octoprint.plugin.types import OctoPrintPlugin, SettingsPlugin from octoprint.settings import settings as s -from octoprint.plugin.core import (PluginInfo, PluginManager, Plugin) -from octoprint.plugin.types import * - -from octoprint.util import deprecated +from octoprint.util import deprecated, to_native_str # singleton _instance = None + def _validate_plugin(phase, plugin_info): - if phase == "after_load": - if plugin_info.implementation is not None and isinstance(plugin_info.implementation, AppPlugin): - # transform app plugin into hook - import warnings - warnings.warn("{name} uses deprecated plugin mixin AppPlugin, use octoprint.accesscontrol.appkey hook instead".format(name=plugin_info.key), DeprecationWarning) - - hooks = plugin_info.hooks - if not "octoprint.accesscontrol.appkey" in hooks: - hooks["octoprint.accesscontrol.appkey"] = plugin_info.implementation.get_additional_apps - setattr(plugin_info.instance, PluginInfo.attr_hooks, hooks) - return True - -def plugin_manager(init=False, plugin_folders=None, plugin_bases=None, plugin_entry_points=None, plugin_disabled_list=None, - plugin_blacklist=None, plugin_restart_needing_hooks=None, plugin_obsolete_hooks=None, - plugin_validators=None): - """ - Factory method for initially constructing and consecutively retrieving the :class:`~octoprint.plugin.core.PluginManager` - singleton. - - Arguments: - init (boolean): A flag indicating whether this is the initial call to construct the singleton (True) or not - (False, default). If this is set to True and the plugin manager has already been initialized, a :class:`ValueError` - will be raised. The same will happen if the plugin manager has not yet been initialized and this is set to - False. - plugin_folders (list): A list of folders (as strings containing the absolute path to them) in which to look for - potential plugin modules. If not provided this defaults to the configured ``plugins`` base folder and - ``src/plugins`` within OctoPrint's code base. - plugin_bases (list): A list of recognized plugin base classes for which to look for provided implementations. If not - provided this defaults to :class:`~octoprint.plugin.OctoPrintPlugin`. - plugin_entry_points (list): A list of entry points pointing to modules which to load as plugins. If not provided - this defaults to the entry point ``octoprint.plugin``. - plugin_disabled_list (list): A list of plugin identifiers that are currently disabled. If not provided this - defaults to all plugins for which ``enabled`` is set to ``False`` in the settings. - plugin_blacklist (list): A list of plugin identifiers/identifier-version tuples that are currently blacklisted. - plugin_restart_needing_hooks (list): A list of hook namespaces which cause a plugin to need a restart in order - be enabled/disabled. Does not have to contain full hook identifiers, will be matched with startswith similar - to logging handlers - plugin_obsolete_hooks (list): A list of hooks that have been declared obsolete. Plugins implementing them will - not be enabled since they might depend on functionality that is no longer available. - plugin_validators (list): A list of additional plugin validators through which to process each plugin. - - Returns: - PluginManager: A fully initialized :class:`~octoprint.plugin.core.PluginManager` instance to be used for plugin - management tasks. - - Raises: - ValueError: ``init`` was True although the plugin manager was already initialized, or it was False although - the plugin manager was not yet initialized. - """ - - global _instance - if _instance is not None: - if init: - raise ValueError("Plugin Manager already initialized") - - else: - if init: - if plugin_bases is None: - plugin_bases = [OctoPrintPlugin] - - if plugin_restart_needing_hooks is None: - plugin_restart_needing_hooks = ["octoprint.server.http.*", - "octoprint.printer.factory"] - - if plugin_obsolete_hooks is None: - plugin_obsolete_hooks = ["octoprint.comm.protocol.gcode"] - - if plugin_validators is None: - plugin_validators = [_validate_plugin] - else: - plugin_validators.append(_validate_plugin) - - _instance = PluginManager(plugin_folders, - plugin_bases, - plugin_entry_points, - logging_prefix="octoprint.plugins.", - plugin_disabled_list=plugin_disabled_list, - plugin_blacklist=plugin_blacklist, - plugin_restart_needing_hooks=plugin_restart_needing_hooks, - plugin_obsolete_hooks=plugin_obsolete_hooks, - plugin_validators=plugin_validators) - else: - raise ValueError("Plugin Manager not initialized yet") - return _instance - - -def plugin_settings(plugin_key, defaults=None, get_preprocessors=None, set_preprocessors=None, settings=None): - """ - Factory method for creating a :class:`PluginSettings` instance. - - Arguments: - plugin_key (string): The plugin identifier for which to create the settings instance. - defaults (dict): The default settings for the plugin, if different from get_settings_defaults. - get_preprocessors (dict): The getter preprocessors for the plugin. - set_preprocessors (dict): The setter preprocessors for the plugin. - settings (octoprint.settings.Settings): The settings instance to use. - - Returns: - PluginSettings: A fully initialized :class:`PluginSettings` instance to be used to access the plugin's - settings - """ - if settings is None: - settings = s() - return PluginSettings(settings, plugin_key, defaults=defaults, - get_preprocessors=get_preprocessors, - set_preprocessors=set_preprocessors) + return True + + +def plugin_manager( + init=False, + plugin_folders=None, + plugin_bases=None, + plugin_entry_points=None, + plugin_disabled_list=None, + plugin_blacklist=None, + plugin_restart_needing_hooks=None, + plugin_obsolete_hooks=None, + plugin_considered_bundled=None, + plugin_validators=None, + compatibility_ignored_list=None, +): + """ + Factory method for initially constructing and consecutively retrieving the :class:`~octoprint.plugin.core.PluginManager` + singleton. + + Arguments: + init (boolean): A flag indicating whether this is the initial call to construct the singleton (True) or not + (False, default). If this is set to True and the plugin manager has already been initialized, a :class:`ValueError` + will be raised. The same will happen if the plugin manager has not yet been initialized and this is set to + False. + plugin_folders (list): A list of folders (as strings containing the absolute path to them) in which to look for + potential plugin modules. If not provided this defaults to the configured ``plugins`` base folder and + ``src/plugins`` within OctoPrint's code base. + plugin_bases (list): A list of recognized plugin base classes for which to look for provided implementations. If not + provided this defaults to :class:`~octoprint.plugin.OctoPrintPlugin`. + plugin_entry_points (list): A list of entry points pointing to modules which to load as plugins. If not provided + this defaults to the entry point ``octoprint.plugin``. + plugin_disabled_list (list): A list of plugin identifiers that are currently disabled. If not provided this + defaults to all plugins for which ``enabled`` is set to ``False`` in the settings. + plugin_blacklist (list): A list of plugin identifiers/identifier-requirement tuples + that are currently blacklisted. + plugin_restart_needing_hooks (list): A list of hook namespaces which cause a plugin to need a restart in order + be enabled/disabled. Does not have to contain full hook identifiers, will be matched with startswith similar + to logging handlers + plugin_obsolete_hooks (list): A list of hooks that have been declared obsolete. Plugins implementing them will + not be enabled since they might depend on functionality that is no longer available. + plugin_considered_bundled (list): A list of plugin identifiers that are considered bundled plugins even if + installed separately. + plugin_validators (list): A list of additional plugin validators through which to process each plugin. + compatibility_ignored_list (list): A list of plugin keys for which it will be ignored if they are flagged as + incompatible. This is for development purposes only and should not be used in production. + + Returns: + PluginManager: A fully initialized :class:`~octoprint.plugin.core.PluginManager` instance to be used for plugin + management tasks. + + Raises: + ValueError: ``init`` was True although the plugin manager was already initialized, or it was False although + the plugin manager was not yet initialized. + """ + + global _instance + if _instance is not None: + if init: + raise ValueError("Plugin Manager already initialized") + + else: + if init: + if plugin_bases is None: + plugin_bases = [OctoPrintPlugin] + + if plugin_restart_needing_hooks is None: + plugin_restart_needing_hooks = [ + "octoprint.server.http.*", + "octoprint.printer.factory", + "octoprint.access.permissions", + "octoprint.timelapse.extensions", + ] + + if plugin_obsolete_hooks is None: + plugin_obsolete_hooks = ["octoprint.comm.protocol.gcode"] + + if plugin_considered_bundled is None: + plugin_considered_bundled = ["firmware_check", "file_check", "pi_support"] + + if plugin_validators is None: + plugin_validators = [_validate_plugin] + else: + plugin_validators.append(_validate_plugin) + + _instance = PluginManager( + plugin_folders, + plugin_bases, + plugin_entry_points, + logging_prefix="octoprint.plugins.", + plugin_disabled_list=plugin_disabled_list, + plugin_blacklist=plugin_blacklist, + plugin_restart_needing_hooks=plugin_restart_needing_hooks, + plugin_obsolete_hooks=plugin_obsolete_hooks, + plugin_considered_bundled=plugin_considered_bundled, + plugin_validators=plugin_validators, + compatibility_ignored_list=compatibility_ignored_list, + ) + else: + raise ValueError("Plugin Manager not initialized yet") + return _instance + + +def plugin_settings( + plugin_key, + defaults=None, + get_preprocessors=None, + set_preprocessors=None, + settings=None, +): + """ + Factory method for creating a :class:`PluginSettings` instance. + + Arguments: + plugin_key (string): The plugin identifier for which to create the settings instance. + defaults (dict): The default settings for the plugin, if different from get_settings_defaults. + get_preprocessors (dict): The getter preprocessors for the plugin. + set_preprocessors (dict): The setter preprocessors for the plugin. + settings (octoprint.settings.Settings): The settings instance to use. + + Returns: + PluginSettings: A fully initialized :class:`PluginSettings` instance to be used to access the plugin's + settings + """ + if settings is None: + settings = s() + return PluginSettings( + settings, + plugin_key, + defaults=defaults, + get_preprocessors=get_preprocessors, + set_preprocessors=set_preprocessors, + ) def plugin_settings_for_settings_plugin(plugin_key, instance, settings=None): - """ - Factory method for creating a :class:`PluginSettings` instance for a given :class:`SettingsPlugin` instance. - - Will return `None` if the provided `instance` is not a :class:`SettingsPlugin` instance. - - Arguments: - plugin_key (string): The plugin identifier for which to create the settings instance. - implementation (octoprint.plugin.SettingsPlugin): The :class:`SettingsPlugin` instance. - settings (octoprint.settings.Settings): The settings instance to use. Defaults to the global OctoPrint settings. - - Returns: - PluginSettings or None: A fully initialized :class:`PluginSettings` instance to be used to access the plugin's - settings, or `None` if the provided `instance` was not a class:`SettingsPlugin` - """ - if not isinstance(instance, SettingsPlugin): - return None - - try: - get_preprocessors, set_preprocessors = instance.get_settings_preprocessors() - except: - logging.getLogger(__name__).exception("Error while retrieving preprocessors for plugin {}".format(plugin_key)) - return None - - return plugin_settings(plugin_key, get_preprocessors=get_preprocessors, set_preprocessors=set_preprocessors, settings=settings) - - -def call_plugin(types, method, args=None, kwargs=None, callback=None, error_callback=None, sorting_context=None, initialized=True): - """ - Helper method to invoke the indicated ``method`` on all registered plugin implementations implementing the - indicated ``types``. Allows providing method arguments and registering callbacks to call in case of success - and/or failure of each call which can be used to return individual results to the calling code. - - Example: - - .. sourcecode:: python - - def my_success_callback(name, plugin, result): - print("{name} was called successfully and returned {result!r}".format(**locals())) - - def my_error_callback(name, plugin, exc): - print("{name} raised an exception: {exc!s}".format(**locals())) - - octoprint.plugin.call_plugin( - [octoprint.plugin.StartupPlugin], - "on_startup", - args=(my_host, my_port), - callback=my_success_callback, - error_callback=my_error_callback - ) - - Arguments: - types (list): A list of plugin implementation types to match against. - method (string): Name of the method to call on all matching implementations. - args (tuple): A tuple containing the arguments to supply to the called ``method``. Optional. - kwargs (dict): A dictionary containing the keyword arguments to supply to the called ``method``. Optional. - callback (function): A callback to invoke after an implementation has been called successfully. Will be called - with the three arguments ``name``, ``plugin`` and ``result``. ``name`` will be the plugin identifier, - ``plugin`` the plugin implementation instance itself and ``result`` the result returned from the - ``method`` invocation. - error_callback (function): A callback to invoke after the call of an implementation resulted in an exception. - Will be called with the three arguments ``name``, ``plugin`` and ``exc``. ``name`` will be the plugin - identifier, ``plugin`` the plugin implementation instance itself and ``exc`` the caught exception. - initialized (boolean): Whether the plugin needs to be initialized (True) or not (False). Initialization status - is determined be presence of injected ``_identifier`` property. - - """ - - if not isinstance(types, (list, tuple)): - types = [types] - if args is None: - args = [] - if kwargs is None: - kwargs = dict() - - plugins = plugin_manager().get_implementations(*types, sorting_context=sorting_context) - for plugin in plugins: - if initialized and not hasattr(plugin, "_identifier"): - continue - - if hasattr(plugin, method): - try: - result = getattr(plugin, method)(*args, **kwargs) - if callback: - callback(plugin._identifier, plugin, result) - except Exception as exc: - logging.getLogger(__name__).exception("Error while calling plugin %s" % plugin._identifier) - if error_callback: - error_callback(plugin._identifier, plugin, exc) + """ + Factory method for creating a :class:`PluginSettings` instance for a given :class:`SettingsPlugin` instance. + + Will return `None` if the provided `instance` is not a :class:`SettingsPlugin` instance. + + Arguments: + plugin_key (string): The plugin identifier for which to create the settings instance. + implementation (octoprint.plugin.SettingsPlugin): The :class:`SettingsPlugin` instance. + settings (octoprint.settings.Settings): The settings instance to use. Defaults to the global OctoPrint settings. + + Returns: + PluginSettings or None: A fully initialized :class:`PluginSettings` instance to be used to access the plugin's + settings, or `None` if the provided `instance` was not a class:`SettingsPlugin` + """ + if not isinstance(instance, SettingsPlugin): + return None + + try: + get_preprocessors, set_preprocessors = instance.get_settings_preprocessors() + except Exception: + logging.getLogger(__name__).exception( + "Error while retrieving preprocessors for plugin {}".format(plugin_key) + ) + return None + + return plugin_settings( + plugin_key, + get_preprocessors=get_preprocessors, + set_preprocessors=set_preprocessors, + settings=settings, + ) + + +def call_plugin( + types, + method, + args=None, + kwargs=None, + callback=None, + error_callback=None, + sorting_context=None, + initialized=True, +): + """ + Helper method to invoke the indicated ``method`` on all registered plugin implementations implementing the + indicated ``types``. Allows providing method arguments and registering callbacks to call in case of success + and/or failure of each call which can be used to return individual results to the calling code. + + Example: + + .. sourcecode:: python + + def my_success_callback(name, plugin, result): + print("{name} was called successfully and returned {result!r}".format(**locals())) + + def my_error_callback(name, plugin, exc): + print("{name} raised an exception: {exc!s}".format(**locals())) + + octoprint.plugin.call_plugin( + [octoprint.plugin.StartupPlugin], + "on_startup", + args=(my_host, my_port), + callback=my_success_callback, + error_callback=my_error_callback + ) + + Arguments: + types (list): A list of plugin implementation types to match against. + method (string): Name of the method to call on all matching implementations. + args (tuple): A tuple containing the arguments to supply to the called ``method``. Optional. + kwargs (dict): A dictionary containing the keyword arguments to supply to the called ``method``. Optional. + callback (function): A callback to invoke after an implementation has been called successfully. Will be called + with the three arguments ``name``, ``plugin`` and ``result``. ``name`` will be the plugin identifier, + ``plugin`` the plugin implementation instance itself and ``result`` the result returned from the + ``method`` invocation. + error_callback (function): A callback to invoke after the call of an implementation resulted in an exception. + Will be called with the three arguments ``name``, ``plugin`` and ``exc``. ``name`` will be the plugin + identifier, ``plugin`` the plugin implementation instance itself and ``exc`` the caught exception. + initialized (boolean): Ignored. + """ + + if not isinstance(types, (list, tuple)): + types = [types] + if args is None: + args = [] + if kwargs is None: + kwargs = {} + + logger = logging.getLogger(__name__) + + plugins = plugin_manager().get_implementations( + *types, sorting_context=sorting_context + ) + for plugin in plugins: + if not hasattr(plugin, "_identifier"): + continue + + if hasattr(plugin, method): + logger.debug("Calling {} on {}".format(method, plugin._identifier)) + try: + result = getattr(plugin, method)(*args, **kwargs) + if callback: + callback(plugin._identifier, plugin, result) + except Exception as exc: + logger.exception( + "Error while calling plugin %s" % plugin._identifier, + extra={"plugin": plugin._identifier}, + ) + if error_callback: + error_callback(plugin._identifier, plugin, exc) class PluginSettings(object): - """ - The :class:`PluginSettings` class is the interface for plugins to their own or globally defined settings. + """ + The :class:`PluginSettings` class is the interface for plugins to their own or globally defined settings. + + It provides some convenience methods for directly accessing plugin settings via the regular + :class:`octoprint.settings.Settings` interfaces as well as means to access plugin specific folder locations. + + All getter and setter methods will ensure that plugin settings are stored in their correct location within the + settings structure by modifying the supplied paths accordingly. + + Arguments: + settings (Settings): The :class:`~octoprint.settings.Settings` instance on which to operate. + plugin_key (str): The plugin identifier of the plugin for which to create this instance. + defaults (dict): The plugin's defaults settings, will be used to determine valid paths within the plugin's + settings structure + + .. method:: get(path, merged=False, asdict=False) + + Retrieves a raw value from the settings for ``path``, optionally merging the raw value with the default settings + if ``merged`` is set to True. + + :param path: The path for which to retrieve the value. + :type path: list, tuple + :param boolean merged: Whether to merge the returned result with the default settings (True) or not (False, + default). + :returns: The retrieved settings value. + :rtype: object + + .. method:: get_int(path, min=None, max=None) + + Like :func:`get` but tries to convert the retrieved value to ``int``. If ``min`` is provided and the retrieved + value is less than it, it will be returned instead of the value. Likewise for ``max`` - it will be returned if + the value is greater than it. + + .. method:: get_float(path, min=None, max=None) + + Like :func:`get` but tries to convert the retrieved value to ``float``. If ``min`` is provided and the retrieved + value is less than it, it will be returned instead of the value. Likewise for ``max`` - it will be returned if + the value is greater than it. + + .. method:: get_boolean(path) - It provides some convenience methods for directly accessing plugin settings via the regular - :class:`octoprint.settings.Settings` interfaces as well as means to access plugin specific folder locations. + Like :func:`get` but tries to convert the retrieved value to ``boolean``. - All getter and setter methods will ensure that plugin settings are stored in their correct location within the - settings structure by modifying the supplied paths accordingly. + .. method:: set(path, value, force=False) - Arguments: - settings (Settings): The :class:`~octoprint.settings.Settings` instance on which to operate. - plugin_key (str): The plugin identifier of the plugin for which to create this instance. - defaults (dict): The plugin's defaults settings, will be used to determine valid paths within the plugin's - settings structure + Sets the raw value on the settings for ``path``. - .. method:: get(path, merged=False, asdict=False) - - Retrieves a raw value from the settings for ``path``, optionally merging the raw value with the default settings - if ``merged`` is set to True. - - :param path: The path for which to retrieve the value. - :type path: list, tuple - :param boolean merged: Whether to merge the returned result with the default settings (True) or not (False, - default). - :returns: The retrieved settings value. - :rtype: object - - .. method:: get_int(path, min=None, max=None) - - Like :func:`get` but tries to convert the retrieved value to ``int``. If ``min`` is provided and the retrieved - value is less than it, it will be returned instead of the value. Likewise for ``max`` - it will be returned if - the value is greater than it. - - .. method:: get_float(path, min=None, max=None) - - Like :func:`get` but tries to convert the retrieved value to ``float``. If ``min`` is provided and the retrieved - value is less than it, it will be returned instead of the value. Likewise for ``max`` - it will be returned if - the value is greater than it. - - .. method:: get_boolean(path) - - Like :func:`get` but tries to convert the retrieved value to ``boolean``. - - .. method:: set(path, value, force=False) - - Sets the raw value on the settings for ``path``. - - :param path: The path for which to retrieve the value. - :type path: list, tuple - :param object value: The value to set. - :param boolean force: If set to True, the modified configuration will even be written back to disk if - the value didn't change. - - .. method:: set_int(path, value, force=False, min=None, max=None) - - Like :func:`set` but ensures the value is an ``int`` through attempted conversion before setting it. - If ``min`` and/or ``max`` are provided, it will also be ensured that the value is greater than or equal - to ``min`` and less than or equal to ``max``. If that is not the case, the limit value (``min`` if less than - that, ``max`` if greater than that) will be set instead. - - .. method:: set_float(path, value, force=False, min=None, max=None) - - Like :func:`set` but ensures the value is an ``float`` through attempted conversion before setting it. - If ``min`` and/or ``max`` are provided, it will also be ensured that the value is greater than or equal - to ``min`` and less than or equal to ``max``. If that is not the case, the limit value (``min`` if less than - that, ``max`` if greater than that) will be set instead. - - .. method:: set_boolean(path, value, force=False) - - Like :func:`set` but ensures the value is an ``boolean`` through attempted conversion before setting it. - """ - - def __init__(self, settings, plugin_key, defaults=None, get_preprocessors=None, set_preprocessors=None): - self.settings = settings - self.plugin_key = plugin_key - - if defaults is not None: - self.defaults = dict(plugins=dict()) - self.defaults["plugins"][plugin_key] = defaults - self.defaults["plugins"][plugin_key]["_config_version"] = None - else: - self.defaults = None - - if get_preprocessors is None: - get_preprocessors = dict() - self.get_preprocessors = dict(plugins=dict()) - self.get_preprocessors["plugins"][plugin_key] = get_preprocessors - - if set_preprocessors is None: - set_preprocessors = dict() - self.set_preprocessors = dict(plugins=dict()) - self.set_preprocessors["plugins"][plugin_key] = set_preprocessors - - def prefix_path_in_args(args, index=0): - result = [] - if index == 0: - result.append(self._prefix_path(args[0])) - result.extend(args[1:]) - else: - args_before = args[:index - 1] - args_after = args[index + 1:] - result.extend(args_before) - result.append(self._prefix_path(args[index])) - result.extend(args_after) - return result - - def add_getter_kwargs(kwargs): - if not "defaults" in kwargs and self.defaults is not None: - kwargs.update(defaults=self.defaults) - if not "preprocessors" in kwargs: - kwargs.update(preprocessors=self.get_preprocessors) - return kwargs - - def add_setter_kwargs(kwargs): - if not "defaults" in kwargs and self.defaults is not None: - kwargs.update(defaults=self.defaults) - if not "preprocessors" in kwargs: - kwargs.update(preprocessors=self.set_preprocessors) - return kwargs - - self.access_methods = dict( - has =("has", prefix_path_in_args, add_getter_kwargs), - get =("get", prefix_path_in_args, add_getter_kwargs), - get_int =("getInt", prefix_path_in_args, add_getter_kwargs), - get_float =("getFloat", prefix_path_in_args, add_getter_kwargs), - get_boolean=("getBoolean", prefix_path_in_args, add_getter_kwargs), - set =("set", prefix_path_in_args, add_setter_kwargs), - set_int =("setInt", prefix_path_in_args, add_setter_kwargs), - set_float =("setFloat", prefix_path_in_args, add_setter_kwargs), - set_boolean=("setBoolean", prefix_path_in_args, add_setter_kwargs), - remove =("remove", prefix_path_in_args, lambda x: x) - ) - self.deprecated_access_methods = dict( - getInt ="get_int", - getFloat ="get_float", - getBoolean="get_boolean", - setInt ="set_int", - setFloat ="set_float", - setBoolean="set_boolean" - ) - - def _prefix_path(self, path=None): - if path is None: - path = list() - return ['plugins', self.plugin_key] + path - - def global_has(self, path, **kwargs): - return self.settings.has(path, **kwargs) - - def global_remove(self, path, **kwargs): - return self.settings.remove(path, **kwargs) - - def global_get(self, path, **kwargs): - """ - Getter for retrieving settings not managed by the plugin itself from the core settings structure. Use this - to access global settings outside of your plugin. - - Directly forwards to :func:`octoprint.settings.Settings.get`. - """ - return self.settings.get(path, **kwargs) - - def global_get_int(self, path, **kwargs): - """ - Like :func:`global_get` but directly forwards to :func:`octoprint.settings.Settings.getInt`. - """ - return self.settings.getInt(path, **kwargs) - - def global_get_float(self, path, **kwargs): - """ - Like :func:`global_get` but directly forwards to :func:`octoprint.settings.Settings.getFloat`. - """ - return self.settings.getFloat(path, **kwargs) - - def global_get_boolean(self, path, **kwargs): - """ - Like :func:`global_get` but directly orwards to :func:`octoprint.settings.Settings.getBoolean`. - """ - return self.settings.getBoolean(path, **kwargs) - - def global_set(self, path, value, **kwargs): - """ - Setter for modifying settings not managed by the plugin itself on the core settings structure. Use this - to modify global settings outside of your plugin. - - Directly forwards to :func:`octoprint.settings.Settings.set`. - """ - self.settings.set(path, value, **kwargs) - - def global_set_int(self, path, value, **kwargs): - """ - Like :func:`global_set` but directly forwards to :func:`octoprint.settings.Settings.setInt`. - """ - self.settings.setInt(path, value, **kwargs) - - def global_set_float(self, path, value, **kwargs): - """ - Like :func:`global_set` but directly forwards to :func:`octoprint.settings.Settings.setFloat`. - """ - self.settings.setFloat(path, value, **kwargs) - - def global_set_boolean(self, path, value, **kwargs): - """ - Like :func:`global_set` but directly forwards to :func:`octoprint.settings.Settings.setBoolean`. - """ - self.settings.setBoolean(path, value, **kwargs) - - def global_get_basefolder(self, folder_type, **kwargs): - """ - Retrieves a globally defined basefolder of the given ``folder_type``. Directly forwards to - :func:`octoprint.settings.Settings.getBaseFolder`. - """ - return self.settings.getBaseFolder(folder_type, **kwargs) - - def get_plugin_logfile_path(self, postfix=None): - """ - Retrieves the path to a logfile specifically for the plugin. If ``postfix`` is not supplied, the logfile - will be named ``plugin_.log`` and located within the configured ``logs`` folder. If a - postfix is supplied, the name will be ``plugin__.log`` at the same location. - - Plugins may use this for specific logging tasks. For example, a :class:`~octoprint.plugin.SlicingPlugin` might - want to create a log file for logging the output of the slicing engine itself if some debug flag is set. - - Arguments: - postfix (str): Postfix of the logfile for which to create the path. If set, the file name of the log file - will be ``plugin__.log``, if not it will be - ``plugin_.log``. - - Returns: - str: Absolute path to the log file, directly usable by the plugin. - """ - filename = "plugin_" + self.plugin_key - if postfix is not None: - filename += "_" + postfix - filename += ".log" - return os.path.join(self.settings.getBaseFolder("logs"), filename) - - @deprecated("PluginSettings.get_plugin_data_folder has been replaced by OctoPrintPlugin.get_plugin_data_folder", - includedoc="Replaced by :func:`~octoprint.plugin.types.OctoPrintPlugin.get_plugin_data_folder`", - since="1.2.0") - def get_plugin_data_folder(self): - path = os.path.join(self.settings.getBaseFolder("data"), self.plugin_key) - if not os.path.isdir(path): - os.makedirs(path) - return path - - def get_all_data(self, **kwargs): - merged = kwargs.get("merged", True) - asdict = kwargs.get("asdict", True) - defaults = kwargs.get("defaults", self.defaults) - preprocessors = kwargs.get("preprocessors", self.get_preprocessors) - - kwargs.update(dict( - merged=merged, - asdict=asdict, - defaults=defaults, - preprocessors=preprocessors - )) - - return self.settings.get(self._prefix_path(), **kwargs) - - def clean_all_data(self): - self.settings.remove(self._prefix_path()) - - def __getattr__(self, item): - all_access_methods = self.access_methods.keys() + self.deprecated_access_methods.keys() - if item in all_access_methods: - decorator = None - if item in self.deprecated_access_methods: - new = self.deprecated_access_methods[item] - decorator = deprecated("{old} has been renamed to {new}".format(old=item, new=new), stacklevel=2) - item = new - - settings_name, args_mapper, kwargs_mapper = self.access_methods[item] - if hasattr(self.settings, settings_name) and callable(getattr(self.settings, settings_name)): - orig_func = getattr(self.settings, settings_name) - if decorator is not None: - orig_func = decorator(orig_func) - - def _func(*args, **kwargs): - return orig_func(*args_mapper(args), **kwargs_mapper(kwargs)) - _func.__name__ = item - _func.__doc__ = orig_func.__doc__ if "__doc__" in dir(orig_func) else None - - return _func - - return getattr(self.settings, item) - - ##~~ deprecated methods follow - - # TODO: Remove with release of 1.3.0 - - globalGet = deprecated("globalGet has been renamed to global_get", - includedoc="Replaced by :func:`global_get`", - since="1.2.0-dev-546")(global_get) - globalGetInt = deprecated("globalGetInt has been renamed to global_get_int", - includedoc="Replaced by :func:`global_get_int`", - since="1.2.0-dev-546")(global_get_int) - globalGetFloat = deprecated("globalGetFloat has been renamed to global_get_float", - includedoc="Replaced by :func:`global_get_float`", - since="1.2.0-dev-546")(global_get_float) - globalGetBoolean = deprecated("globalGetBoolean has been renamed to global_get_boolean", - includedoc="Replaced by :func:`global_get_boolean`", - since="1.2.0-dev-546")(global_get_boolean) - globalSet = deprecated("globalSet has been renamed to global_set", - includedoc="Replaced by :func:`global_set`", - since="1.2.0-dev-546")(global_set) - globalSetInt = deprecated("globalSetInt has been renamed to global_set_int", - includedoc="Replaced by :func:`global_set_int`", - since="1.2.0-dev-546")(global_set_int) - globalSetFloat = deprecated("globalSetFloat has been renamed to global_set_float", - includedoc="Replaced by :func:`global_set_float`", - since="1.2.0-dev-546")(global_set_float) - globalSetBoolean = deprecated("globalSetBoolean has been renamed to global_set_boolean", - includedoc="Replaced by :func:`global_set_boolean`", - since="1.2.0-dev-546")(global_set_boolean) - globalGetBaseFolder = deprecated("globalGetBaseFolder has been renamed to global_get_basefolder", - includedoc="Replaced by :func:`global_get_basefolder`", - since="1.2.0-dev-546")(global_get_basefolder) - getPluginLogfilePath = deprecated("getPluginLogfilePath has been renamed to get_plugin_logfile_path", - includedoc="Replaced by :func:`get_plugin_logfile_path`", - since="1.2.0-dev-546")(get_plugin_logfile_path) + :param path: The path for which to retrieve the value. + :type path: list, tuple + :param object value: The value to set. + :param boolean force: If set to True, the modified configuration will even be written back to disk if + the value didn't change. + + .. method:: set_int(path, value, force=False, min=None, max=None) + + Like :func:`set` but ensures the value is an ``int`` through attempted conversion before setting it. + If ``min`` and/or ``max`` are provided, it will also be ensured that the value is greater than or equal + to ``min`` and less than or equal to ``max``. If that is not the case, the limit value (``min`` if less than + that, ``max`` if greater than that) will be set instead. + + .. method:: set_float(path, value, force=False, min=None, max=None) + + Like :func:`set` but ensures the value is an ``float`` through attempted conversion before setting it. + If ``min`` and/or ``max`` are provided, it will also be ensured that the value is greater than or equal + to ``min`` and less than or equal to ``max``. If that is not the case, the limit value (``min`` if less than + that, ``max`` if greater than that) will be set instead. + + .. method:: set_boolean(path, value, force=False) + + Like :func:`set` but ensures the value is an ``boolean`` through attempted conversion before setting it. + + .. method:: save(force=False, trigger_event=False) + + Saves the settings to ``config.yaml`` if there are active changes. If ``force`` is set to ``True`` the settings + will be saved even if there are no changes. Settings ``trigger_event`` to ``True`` will cause a ``SettingsUpdated`` + :ref:`event ` to get triggered. + + :param force: Force saving to ``config.yaml`` even if there are no changes. + :type force: boolean + :param trigger_event: Trigger the ``SettingsUpdated`` :ref:`event ` on save. + :type trigger_event: boolean + + .. method:: add_overlay(overlay, at_end=False, key=None) + + Adds a new config overlay for the plugin's settings. Will return the overlay's key in the map. + + :param overlay: Overlay dict to add + :type overlay: dict + :param at_end: Whether to add overlay at end or start (default) of config hierarchy + :type at_end: boolean + :param key: Key to use to identify overlay. If not set one will be built based on the overlay's hash + :type key: str + :rtype: str + + .. method:: remove_overlay(key) + + Removes an overlay from the settings based on its key. Return ``True`` if the overlay could be found and was + removed, ``False`` otherwise. + + :param key: The key of the overlay to remove + :type key: str + :rtype: boolean + """ + + def __init__( + self, + settings, + plugin_key, + defaults=None, + get_preprocessors=None, + set_preprocessors=None, + ): + self.settings = settings + self.plugin_key = plugin_key + + if defaults is not None: + self.defaults = {"plugins": {}} + self.defaults["plugins"][plugin_key] = defaults + self.defaults["plugins"][plugin_key]["_config_version"] = None + else: + self.defaults = None + + if get_preprocessors is None: + get_preprocessors = {} + self.get_preprocessors = {"plugins": {}} + self.get_preprocessors["plugins"][plugin_key] = get_preprocessors + + if set_preprocessors is None: + set_preprocessors = {} + self.set_preprocessors = {"plugins": {}} + self.set_preprocessors["plugins"][plugin_key] = set_preprocessors + + def prefix_path_in_args(args, index=0): + result = [] + if index == 0: + result.append(self._prefix_path(args[0])) + result.extend(args[1:]) + else: + args_before = args[: index - 1] + args_after = args[index + 1 :] + result.extend(args_before) + result.append(self._prefix_path(args[index])) + result.extend(args_after) + return result + + def add_getter_kwargs(kwargs): + if "defaults" not in kwargs and self.defaults is not None: + kwargs.update(defaults=self.defaults) + if "preprocessors" not in kwargs: + kwargs.update(preprocessors=self.get_preprocessors) + return kwargs + + def add_setter_kwargs(kwargs): + if "defaults" not in kwargs and self.defaults is not None: + kwargs.update(defaults=self.defaults) + if "preprocessors" not in kwargs: + kwargs.update(preprocessors=self.set_preprocessors) + return kwargs + + def wrap_overlay(args): + result = list(args) + overlay = result[0] + result[0] = {"plugins": {plugin_key: overlay}} + return result + + self.access_methods = { + "has": ("has", prefix_path_in_args, add_getter_kwargs), + "get": ("get", prefix_path_in_args, add_getter_kwargs), + "get_int": ("getInt", prefix_path_in_args, add_getter_kwargs), + "get_float": ("getFloat", prefix_path_in_args, add_getter_kwargs), + "get_boolean": ("getBoolean", prefix_path_in_args, add_getter_kwargs), + "set": ("set", prefix_path_in_args, add_setter_kwargs), + "set_int": ("setInt", prefix_path_in_args, add_setter_kwargs), + "set_float": ("setFloat", prefix_path_in_args, add_setter_kwargs), + "set_boolean": ("setBoolean", prefix_path_in_args, add_setter_kwargs), + "remove": ("remove", prefix_path_in_args, lambda x: x), + "add_overlay": ("add_overlay", wrap_overlay, lambda x: x), + "remove_overlay": ("remove_overlay", lambda x: x, lambda x: x), + } + self.deprecated_access_methods = { + "getInt": "get_int", + "getFloat": "get_float", + "getBoolean": "get_boolean", + "setInt": "set_int", + "setFloat": "set_float", + "setBoolean": "set_boolean", + } + + def _prefix_path(self, path=None): + if path is None: + path = list() + return ["plugins", self.plugin_key] + path + + def global_has(self, path, **kwargs): + return self.settings.has(path, **kwargs) + + def global_remove(self, path, **kwargs): + return self.settings.remove(path, **kwargs) + + def global_get(self, path, **kwargs): + """ + Getter for retrieving settings not managed by the plugin itself from the core settings structure. Use this + to access global settings outside of your plugin. + + Directly forwards to :func:`octoprint.settings.Settings.get`. + """ + return self.settings.get(path, **kwargs) + + def global_get_int(self, path, **kwargs): + """ + Like :func:`global_get` but directly forwards to :func:`octoprint.settings.Settings.getInt`. + """ + return self.settings.getInt(path, **kwargs) + + def global_get_float(self, path, **kwargs): + """ + Like :func:`global_get` but directly forwards to :func:`octoprint.settings.Settings.getFloat`. + """ + return self.settings.getFloat(path, **kwargs) + + def global_get_boolean(self, path, **kwargs): + """ + Like :func:`global_get` but directly orwards to :func:`octoprint.settings.Settings.getBoolean`. + """ + return self.settings.getBoolean(path, **kwargs) + + def global_set(self, path, value, **kwargs): + """ + Setter for modifying settings not managed by the plugin itself on the core settings structure. Use this + to modify global settings outside of your plugin. + + Directly forwards to :func:`octoprint.settings.Settings.set`. + """ + self.settings.set(path, value, **kwargs) + + def global_set_int(self, path, value, **kwargs): + """ + Like :func:`global_set` but directly forwards to :func:`octoprint.settings.Settings.setInt`. + """ + self.settings.setInt(path, value, **kwargs) + + def global_set_float(self, path, value, **kwargs): + """ + Like :func:`global_set` but directly forwards to :func:`octoprint.settings.Settings.setFloat`. + """ + self.settings.setFloat(path, value, **kwargs) + + def global_set_boolean(self, path, value, **kwargs): + """ + Like :func:`global_set` but directly forwards to :func:`octoprint.settings.Settings.setBoolean`. + """ + self.settings.setBoolean(path, value, **kwargs) + + def global_get_basefolder(self, folder_type, **kwargs): + """ + Retrieves a globally defined basefolder of the given ``folder_type``. Directly forwards to + :func:`octoprint.settings.Settings.getBaseFolder`. + """ + return self.settings.getBaseFolder(folder_type, **kwargs) + + def get_plugin_logfile_path(self, postfix=None): + """ + Retrieves the path to a logfile specifically for the plugin. If ``postfix`` is not supplied, the logfile + will be named ``plugin_.log`` and located within the configured ``logs`` folder. If a + postfix is supplied, the name will be ``plugin__.log`` at the same location. + + Plugins may use this for specific logging tasks. For example, a :class:`~octoprint.plugin.SlicingPlugin` might + want to create a log file for logging the output of the slicing engine itself if some debug flag is set. + + Arguments: + postfix (str): Postfix of the logfile for which to create the path. If set, the file name of the log file + will be ``plugin__.log``, if not it will be + ``plugin_.log``. + + Returns: + str: Absolute path to the log file, directly usable by the plugin. + """ + filename = "plugin_" + self.plugin_key + if postfix is not None: + filename += "_" + postfix + filename += ".log" + return os.path.join(self.settings.getBaseFolder("logs"), filename) + + @deprecated( + "PluginSettings.get_plugin_data_folder has been replaced by OctoPrintPlugin.get_plugin_data_folder", + includedoc="Replaced by :func:`~octoprint.plugin.types.OctoPrintPlugin.get_plugin_data_folder`", + since="1.2.0", + ) + def get_plugin_data_folder(self): + path = os.path.join(self.settings.getBaseFolder("data"), self.plugin_key) + if not os.path.isdir(path): + os.makedirs(path) + return path + + def get_all_data(self, **kwargs): + merged = kwargs.get("merged", True) + asdict = kwargs.get("asdict", True) + defaults = kwargs.get("defaults", self.defaults) + preprocessors = kwargs.get("preprocessors", self.get_preprocessors) + + kwargs.update( + { + "merged": merged, + "asdict": asdict, + "defaults": defaults, + "preprocessors": preprocessors, + } + ) + + return self.settings.get(self._prefix_path(), **kwargs) + + def clean_all_data(self): + self.settings.remove(self._prefix_path()) + + def __getattr__(self, item): + all_access_methods = list(self.access_methods.keys()) + list( + self.deprecated_access_methods.keys() + ) + if item in all_access_methods: + decorator = None + if item in self.deprecated_access_methods: + new = self.deprecated_access_methods[item] + decorator = deprecated( + "{old} has been renamed to {new}".format(old=item, new=new), + stacklevel=2, + ) + item = new + + settings_name, args_mapper, kwargs_mapper = self.access_methods[item] + if hasattr(self.settings, settings_name) and callable( + getattr(self.settings, settings_name) + ): + orig_func = getattr(self.settings, settings_name) + if decorator is not None: + orig_func = decorator(orig_func) + + def _func(*args, **kwargs): + return orig_func(*args_mapper(args), **kwargs_mapper(kwargs)) + + _func.__name__ = to_native_str(item) + _func.__doc__ = orig_func.__doc__ if "__doc__" in dir(orig_func) else None + + return _func + + return getattr(self.settings, item) diff --git a/src/octoprint/plugin/core.py b/src/octoprint/plugin/core.py index 2cce7fe1ab..a4daae0fef 100644 --- a/src/octoprint/plugin/core.py +++ b/src/octoprint/plugin/core.py @@ -1,7 +1,5 @@ -# coding=utf-8 """ -In this module resides the core data structures and logic of the plugin system. It is implemented in an OctoPrint-agnostic -way and could be extracted into a separate Python module in the future. +In this module resides the core data structures and logic of the plugin system. .. autoclass:: PluginManager :members: @@ -19,1627 +17,2465 @@ :members: """ +from __future__ import absolute_import, division, print_function, unicode_literals -from __future__ import absolute_import, division, print_function - -__author__ = "Gina Häußge " -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" -import os -import imp -from collections import defaultdict, namedtuple, OrderedDict -import logging import fnmatch import inspect +import io +import logging +import os +import sys +from collections import OrderedDict, defaultdict, namedtuple +from importlib.metadata import entry_points -import pkg_resources import pkginfo +from packaging.requirements import Requirement -from past.builtins import basestring +from octoprint.util import sv, time_this, to_unicode +from octoprint.util.version import get_python_version_string, is_python_compatible try: - from os import scandir + from os import scandir except ImportError: - from scandir import scandir + from scandir import scandir + + +if sys.version_info[0] == 2: + # noinspection PyDeprecation + import imp +else: + # deprecated in Python 3.4+ and hence vendored for now + import octoprint.vendor.imp as imp + + +# noinspection PyDeprecation +def _find_module(name, path=None): + if path is not None: + spec = imp.find_module(name, [path]) + else: + spec = imp.find_module(name) + return spec[1], spec + + +# noinspection PyDeprecation +def _load_module(name, spec): + f, filename, details = spec + return imp.load_module(name, f, filename, details) + + +def parse_plugin_metadata(path): + result = {} + logger = logging.getLogger(__name__) + + if not path: + return result + + if os.path.isdir(path): + path = os.path.join(path, "__init__.py") + + if not os.path.isfile(path): + return result + + if not path.endswith(".py"): + # we only support parsing plain text source files + return result + + logger.debug("Parsing plugin metadata from AST of {}".format(path)) + + try: + import ast + + with io.open(path, "rb") as f: + root = ast.parse(f.read(), filename=path) + + assignments = list( + filter(lambda x: isinstance(x, ast.Assign) and x.targets, root.body) + ) + function_defs = list( + filter(lambda x: isinstance(x, ast.FunctionDef) and x.name, root.body) + ) + all_relevant = assignments + function_defs + + def extract_target_ids(node): + return list( + map( + lambda x: x.id, + filter(lambda x: isinstance(x, ast.Name), node.targets), + ) + ) + + def extract_names(node): + if isinstance(node, ast.Assign): + return extract_target_ids(node) + elif isinstance(node, ast.FunctionDef): + return [ + node.name, + ] + else: + return [] + + for key in ( + ControlProperties.attr_name, + ControlProperties.attr_version, + ControlProperties.attr_author, + ControlProperties.attr_description, + ControlProperties.attr_url, + ControlProperties.attr_license, + ControlProperties.attr_pythoncompat, + ): + for a in reversed(assignments): + targets = extract_target_ids(a) + if key in targets: + if isinstance(a.value, ast.Str): + result[key] = a.value.s -EntryPointOrigin = namedtuple("EntryPointOrigin", "type, entry_point, module_name, package_name, package_version") -FolderOrigin = namedtuple("FolderOrigin", "type, folder") + elif ( + isinstance(a.value, ast.Call) + and hasattr(a.value, "func") + and a.value.func.id == "gettext" + and a.value.args + and isinstance(a.value.args[0], ast.Str) + ): + result[key] = a.value.args[0].s -class PluginInfo(object): - """ - The :class:`PluginInfo` class wraps all available information about a registered plugin. + break - This includes its meta data (like name, description, version, etc) as well as the actual plugin extensions like - implementations, hooks and helpers. + for key in (ControlProperties.attr_hidden,): + for a in reversed(assignments): + targets = extract_target_ids(a) + if key in targets: + if isinstance(a.value, ast.Name) and a.value.id in ( + "True", + "False", + ): + result[key] = bool(a.value.id) - It works on Python module objects and extracts the relevant data from those via accessing the - :ref:`control properties `. + break - Arguments: - key (str): Identifier of the plugin - location (str): Installation folder of the plugin - instance (module): Plugin module instance - this may be ``None`` if the plugin has been blacklisted! - name (str): Human readable name of the plugin - version (str): Version of the plugin - description (str): Description of the plugin - author (str): Author of the plugin - url (str): URL of the website of the plugin - license (str): License of the plugin - """ + for a in reversed(all_relevant): + targets = extract_names(a) + if any(map(lambda x: x in targets, ControlProperties.all())): + result["has_control_properties"] = True + break - attr_name = '__plugin_name__' - """ Module attribute from which to retrieve the plugin's human readable name. """ + except SyntaxError: + raise + except Exception: + logger.exception("Error while parsing AST from {}".format(path)) - attr_description = '__plugin_description__' - """ Module attribute from which to retrieve the plugin's description. """ + return result - attr_disabling_discouraged = '__plugin_disabling_discouraged__' - """ Module attribute from which to retrieve the reason why disabling the plugin is discouraged. Only effective if ``self.bundled`` is True. """ - attr_version = '__plugin_version__' - """ Module attribute from which to retrieve the plugin's version. """ +class ControlProperties(object): + attr_name = "__plugin_name__" + """ Module attribute from which to retrieve the plugin's human readable name. """ - attr_author = '__plugin_author__' - """ Module attribute from which to retrieve the plugin's author. """ + attr_description = "__plugin_description__" + """ Module attribute from which to retrieve the plugin's description. """ - attr_url = '__plugin_url__' - """ Module attribute from which to retrieve the plugin's website URL. """ + attr_disabling_DISCOURAGED = "__plugin_disabling_discouraged__" + """ Module attribute from which to retrieve the reason why disabling the plugin is discouraged. Only effective if ``self.bundled`` is True. """ - attr_license = '__plugin_license__' - """ Module attribute from which to retrieve the plugin's license. """ + attr_version = "__plugin_version__" + """ Module attribute from which to retrieve the plugin's version. """ - attr_hooks = '__plugin_hooks__' - """ Module attribute from which to retrieve the plugin's provided hooks. """ + attr_author = "__plugin_author__" + """ Module attribute from which to retrieve the plugin's author. """ - attr_implementation = '__plugin_implementation__' - """ Module attribute from which to retrieve the plugin's provided mixin implementation. """ + attr_url = "__plugin_url__" + """ Module attribute from which to retrieve the plugin's website URL. """ - attr_implementations = '__plugin_implementations__' - """ - Module attribute from which to retrieve the plugin's provided implementations. + attr_license = "__plugin_license__" + """ Module attribute from which to retrieve the plugin's license. """ - This deprecated attribute will only be used if a plugin does not yet offer :attr:`attr_implementation`. Only the - first entry will be evaluated. + attr_pythoncompat = "__plugin_pythoncompat__" + """ + Module attribute from which to retrieve the plugin's python compatibility string. - .. deprecated:: 1.2.0-dev-694 + If unset a default of ``>=2.7,<3`` will be assumed, meaning that the plugin will be considered compatible to + Python 2 but not Python 3. - Use :attr:`attr_implementation` instead. - """ - - attr_helpers = '__plugin_helpers__' - """ Module attribute from which to retrieve the plugin's provided helpers. """ + To mark a plugin as Python 3 compatible, a string of ``>=2.7,<4`` is recommended. - attr_check = '__plugin_check__' - """ Module attribute which to call to determine if the plugin can be loaded. """ + Bundled plugins will automatically be assumed to be compatible. + """ - attr_init = '__plugin_init__' - """ - Module attribute which to call when loading the plugin. - - This deprecated attribute will only be used if a plugin does not yet offer :attr:`attr_load`. - - .. deprecated:: 1.2.0-dev-720 - - Use :attr:`attr_load` instead. - """ - - attr_load = '__plugin_load__' - """ Module attribute which to call when loading the plugin. """ - - attr_unload = '__plugin_unload__' - """ Module attribute which to call when unloading the plugin. """ - - attr_enable = '__plugin_enable__' - """ Module attribute which to call when enabling the plugin. """ - - attr_disable = '__plugin_disable__' - """ Module attribute which to call when disabling the plugin. """ - - def __init__(self, key, location, instance, name=None, version=None, description=None, author=None, url=None, license=None): - self.key = key - self.location = location - self.instance = instance - self.origin = None - self.enabled = True - self.blacklisted = False - self.bundled = False - self.loaded = False - self.managable = True - self.needs_restart = False - - self._name = name - self._version = version - self._description = description - self._author = author - self._url = url - self._license = license - - def validate(self, phase, additional_validators=None): - result = True - - if phase == "before_load": - # if the plugin still uses __plugin_init__, log a deprecation warning and move it to __plugin_load__ - if hasattr(self.instance, self.__class__.attr_init): - if not hasattr(self.instance, self.__class__.attr_load): - # deprecation warning - import warnings - warnings.warn("{name} uses deprecated control property __plugin_init__, use __plugin_load__ instead".format(name=self.key), DeprecationWarning) - - # move it - init = getattr(self.instance, self.__class__.attr_init) - setattr(self.instance, self.__class__.attr_load, init) - - # delete __plugin_init__ - delattr(self.instance, self.__class__.attr_init) - - elif phase == "after_load": - # if the plugin still uses __plugin_implementations__, log a deprecation warning and put the first - # item into __plugin_implementation__ - if hasattr(self.instance, self.__class__.attr_implementations): - if not hasattr(self.instance, self.__class__.attr_implementation): - # deprecation warning - import warnings - warnings.warn("{name} uses deprecated control property __plugin_implementations__, use __plugin_implementation__ instead - only the first implementation of {name} will be recognized".format(name=self.key), DeprecationWarning) - - # put first item into __plugin_implementation__ - implementations = getattr(self.instance, self.__class__.attr_implementations) - if len(implementations) > 0: - setattr(self.instance, self.__class__.attr_implementation, implementations[0]) - - # delete __plugin_implementations__ - delattr(self.instance, self.__class__.attr_implementations) - - if additional_validators is not None: - for validator in additional_validators: - result = result and validator(phase, self) - - return result - - def __str__(self): - if self.version: - return "{name} ({version})".format(name=self.name, version=self.version) - else: - return self.name - - def long_str(self, show_bundled=False, bundled_strs=(" [B]", ""), - show_location=False, location_str=" - {location}", - show_enabled=False, enabled_strs=("* ", " ", "X ")): - """ - Long string representation of the plugin's information. Will return a string of the format ````. - - ``enabled``, ``bundled`` and ``location`` will only be displayed if the corresponding flags are set to ``True``. - The will be filled from ``enabled_str``, ``bundled_str`` and ``location_str`` as follows: - - ``enabled_str`` - a 3-tuple, the first entry being the string to insert when the plugin is enabled, the second - entry the string to insert when it is not, the third entry the string when it is blacklisted. - ``bundled_str`` - a 2-tuple, the first entry being the string to insert when the plugin is bundled, the second - entry the string to insert when it is not. - ``location_str`` - a format string (to be parsed with ``str.format``), the ``{location}`` placeholder will be - replaced with the plugin's installation folder on disk. - - Arguments: - show_enabled (boolean): whether to show the ``enabled`` part - enabled_strs (tuple): the 2-tuple containing the two possible strings to use for displaying the enabled state - show_bundled (boolean): whether to show the ``bundled`` part - bundled_strs(tuple): the 2-tuple containing the two possible strings to use for displaying the bundled state - show_location (boolean): whether to show the ``location`` part - location_str (str): the format string to use for displaying the plugin's installation location - - Returns: - str: The long string representation of the plugin as described above - """ - if show_enabled: - ret = enabled_strs[2] if self.blacklisted else (enabled_strs[0] if self.enabled else enabled_strs[1]) - else: - ret = "" - - ret += str(self) - - if show_bundled: - ret += bundled_strs[0] if self.bundled else bundled_strs[1] - - if show_location and self.location: - ret += location_str.format(location=self.location) - - return ret - - def get_hook(self, hook): - """ - Arguments: - hook (str): Hook to return. - - Returns: - callable or None: Handler for the requested ``hook`` or None if no handler is registered. - """ - - if not hook in self.hooks: - return None - return self.hooks[hook] - - def get_implementation(self, *types): - """ - Arguments: - types (list): List of :class:`Plugin` sub classes all returned implementations need to implement. - - Returns: - object: The plugin's implementation if it matches all of the requested ``types``, None otherwise. - """ - - if not self.implementation: - return None - - for t in types: - if not isinstance(self.implementation, t): - return None - - return self.implementation - - @property - def name(self): - """ - Human readable name of the plugin. Will be taken from name attribute of the plugin module if available, - otherwise from the ``name`` supplied during construction with a fallback to ``key``. - - Returns: - str: Name of the plugin, fallback is the plugin's identifier. - """ - return self._get_instance_attribute(self.__class__.attr_name, defaults=(self._name, self.key)) - - @property - def description(self): - """ - Description of the plugin. Will be taken from the description attribute of the plugin module as defined in - :attr:`attr_description` if available, otherwise from the ``description`` supplied during construction. - May be None. - - Returns: - str or None: Description of the plugin. - """ - return self._get_instance_attribute(self.__class__.attr_description, default=self._description) - - @property - def disabling_discouraged(self): - """ - Reason why disabling of this plugin is discouraged. Only evaluated for bundled plugins! Will be taken from - the disabling_discouraged attribute of the plugin module as defined in :attr:`attr_disabling_discouraged` if - available. False if unset or plugin not bundled. - - Returns: - str or None: Reason why disabling this plugin is discouraged (only for bundled plugins) - """ - return self._get_instance_attribute(self.__class__.attr_disabling_discouraged, default=False) if self.bundled \ - else False - - @property - def version(self): - """ - Version of the plugin. Will be taken from the version attribute of the plugin module as defined in - :attr:`attr_version` if available, otherwise from the ``version`` supplied during construction. May be None. - - Returns: - str or None: Version of the plugin. - """ - return self._version if self._version is not None else self._get_instance_attribute(self.__class__.attr_version, default=self._version) - - @property - def author(self): - """ - Author of the plugin. Will be taken from the author attribute of the plugin module as defined in - :attr:`attr_author` if available, otherwise from the ``author`` supplied during construction. May be None. - - Returns: - str or None: Author of the plugin. - """ - return self._get_instance_attribute(self.__class__.attr_author, default=self._author) - - @property - def url(self): - """ - Website URL for the plugin. Will be taken from the url attribute of the plugin module as defined in - :attr:`attr_url` if available, otherwise from the ``url`` supplied during construction. May be None. - - Returns: - str or None: Website URL for the plugin. - """ - return self._get_instance_attribute(self.__class__.attr_url, default=self._url) - - @property - def license(self): - """ - License of the plugin. Will be taken from the license attribute of the plugin module as defined in - :attr:`attr_license` if available, otherwise from the ``license`` supplied during construction. May be None. - - Returns: - str or None: License of the plugin. - """ - return self._get_instance_attribute(self.__class__.attr_license, default=self._license) - - @property - def hooks(self): - """ - Hooks provided by the plugin. Will be taken from the hooks attribute of the plugin module as defiend in - :attr:`attr_hooks` if available, otherwise an empty dictionary is returned. - - Returns: - dict: Hooks provided by the plugin. - """ - return self._get_instance_attribute(self.__class__.attr_hooks, default={}) - - @property - def implementation(self): - """ - Implementation provided by the plugin. Will be taken from the implementation attribute of the plugin module - as defined in :attr:`attr_implementation` if available, otherwise None is returned. - - Returns: - object: Implementation provided by the plugin. - """ - return self._get_instance_attribute(self.__class__.attr_implementation, default=None) - - @property - def helpers(self): - """ - Helpers provided by the plugin. Will be taken from the helpers attribute of the plugin module as defined in - :attr:`attr_helpers` if available, otherwise an empty list is returned. - - Returns: - dict: Helpers provided by the plugin. - """ - return self._get_instance_attribute(self.__class__.attr_helpers, default={}) - - @property - def check(self): - """ - Method for pre-load check of plugin. Will be taken from the check attribute of the plugin module as defined in - :attr:`attr_check` if available, otherwise a lambda always returning True is returned. - - Returns: - callable: Check method for the plugin module which should return True if the plugin can be loaded, False - otherwise. - """ - return self._get_instance_attribute(self.__class__.attr_check, default=lambda: True) - - @property - def load(self): - """ - Method for loading the plugin module. Will be taken from the load attribute of the plugin module as defined - in :attr:`attr_load` if available, otherwise a no-operation lambda will be returned. - - Returns: - callable: Load method for the plugin module. - """ - return self._get_instance_attribute(self.__class__.attr_load, default=lambda: True) - - @property - def unload(self): - """ - Method for unloading the plugin module. Will be taken from the unload attribute of the plugin module as defined - in :attr:`attr_unload` if available, otherwise a no-operation lambda will be returned. - - Returns: - callable: Unload method for the plugin module. - """ - return self._get_instance_attribute(self.__class__.attr_unload, default=lambda: True) - - @property - def enable(self): - """ - Method for enabling the plugin module. Will be taken from the enable attribute of the plugin module as defined - in :attr:`attr_enable` if available, otherwise a no-operation lambda will be returned. - - Returns: - callable: Enable method for the plugin module. - """ - return self._get_instance_attribute(self.__class__.attr_enable, default=lambda: True) - - @property - def disable(self): - """ - Method for disabling the plugin module. Will be taken from the disable attribute of the plugin module as defined - in :attr:`attr_disable` if available, otherwise a no-operation lambda will be returned. - - Returns: - callable: Disable method for the plugin module. - """ - return self._get_instance_attribute(self.__class__.attr_disable, default=lambda: True) - - def _get_instance_attribute(self, attr, default=None, defaults=None): - if self.instance is None or not hasattr(self.instance, attr): - if defaults is not None: - for value in defaults: - if value is not None: - return value - return default - return getattr(self.instance, attr) + attr_hidden = "__plugin_hidden__" + """ + Module attribute from which to determine if the plugin's hidden or not. + Only evaluated for bundled plugins, in order to hide them from the Plugin Manager + and similar places. + """ + + attr_hooks = "__plugin_hooks__" + """ Module attribute from which to retrieve the plugin's provided hooks. """ + + attr_implementation = "__plugin_implementation__" + """ Module attribute from which to retrieve the plugin's provided mixin implementation. """ + + attr_helpers = "__plugin_helpers__" + """ Module attribute from which to retrieve the plugin's provided helpers. """ + + attr_check = "__plugin_check__" + """ Module attribute which to call to determine if the plugin can be loaded. """ + + attr_load = "__plugin_load__" + """ Module attribute which to call when loading the plugin. """ + + attr_unload = "__plugin_unload__" + """ Module attribute which to call when unloading the plugin. """ + + attr_enable = "__plugin_enable__" + """ Module attribute which to call when enabling the plugin. """ + + attr_disable = "__plugin_disable__" + """ Module attribute which to call when disabling the plugin. """ + + default_pythoncompat = ">=2.7,<3" + + @classmethod + def all(cls): + return [getattr(cls, key) for key in dir(cls) if key.startswith("attr_")] + + +_EntryPointOrigin = namedtuple( + "EntryPointOrigin", "type, entry_point, module_name, package_name, package_version" +) -class PluginManager(object): - """ - The :class:`PluginManager` is the central component for finding, loading and accessing plugins provided to the - system. - - It is able to discover plugins both through possible file system locations as well as customizable entry points. - """ - - def __init__(self, plugin_folders, plugin_bases, plugin_entry_points, logging_prefix=None, - plugin_disabled_list=None, plugin_blacklist=None, plugin_restart_needing_hooks=None, - plugin_obsolete_hooks=None, plugin_validators=None): - self.logger = logging.getLogger(__name__) - - if logging_prefix is None: - logging_prefix = "" - if plugin_folders is None: - plugin_folders = [] - if plugin_bases is None: - plugin_bases = [] - if plugin_entry_points is None: - plugin_entry_points = [] - if plugin_disabled_list is None: - plugin_disabled_list = [] - if plugin_blacklist is None: - plugin_blacklist = [] - - self.plugin_folders = plugin_folders - self.plugin_bases = plugin_bases - self.plugin_entry_points = plugin_entry_points - self.plugin_disabled_list = plugin_disabled_list - self.plugin_blacklist = plugin_blacklist - self.plugin_restart_needing_hooks = plugin_restart_needing_hooks - self.plugin_obsolete_hooks = plugin_obsolete_hooks - self.plugin_validators = plugin_validators - self.logging_prefix = logging_prefix - - self.enabled_plugins = dict() - self.disabled_plugins = dict() - self.plugin_implementations = dict() - self.plugin_implementations_by_type = defaultdict(list) - - self._plugin_hooks = defaultdict(list) - - self.implementation_injects = dict() - self.implementation_inject_factories = [] - self.implementation_pre_inits = [] - self.implementation_post_inits = [] - - self.on_plugin_loaded = lambda *args, **kwargs: None - self.on_plugin_unloaded = lambda *args, **kwargs: None - self.on_plugin_enabled = lambda *args, **kwargs: None - self.on_plugin_disabled = lambda *args, **kwargs: None - self.on_plugin_implementations_initialized = lambda *args, **kwargs: None - - self.on_plugins_loaded = lambda *args, **kwargs: None - self.on_plugins_enabled = lambda *args, **kwargs: None - - self.registered_clients = [] - - self.marked_plugins = defaultdict(list) - - self._python_install_dir = None - self._python_virtual_env = False - self._detect_python_environment() - - def _detect_python_environment(self): - from distutils.command.install import install as cmd_install - from distutils.dist import Distribution - import sys - - cmd = cmd_install(Distribution()) - cmd.finalize_options() - - self._python_install_dir = cmd.install_lib - self._python_prefix = os.path.realpath(sys.prefix) - self._python_virtual_env = hasattr(sys, "real_prefix") \ - or (hasattr(sys, "base_prefix") and os.path.realpath(sys.prefix) != os.path.realpath(sys.base_prefix)) - - @property - def plugins(self): - plugins = dict(self.enabled_plugins) - plugins.update(self.disabled_plugins) - return plugins - - @property - def plugin_hooks(self): - return {key: map(lambda v: (v[1], v[2]), value) for key, value in self._plugin_hooks.items()} - - def find_plugins(self, existing=None, ignore_uninstalled=True): - if existing is None: - existing = dict(self.plugins) - - result = OrderedDict() - if self.plugin_folders: - try: - result.update(self._find_plugins_from_folders(self.plugin_folders, - existing, - ignored_uninstalled=ignore_uninstalled)) - except: - self.logger.exception("Error fetching plugins from folders") - if self.plugin_entry_points: - existing.update(result) - try: - result.update(self._find_plugins_from_entry_points(self.plugin_entry_points, - existing, - ignore_uninstalled=ignore_uninstalled)) - except: - self.logger.exception("Error fetching plugins from entry points") - return result - - def _find_plugins_from_folders(self, folders, existing, ignored_uninstalled=True): - result = OrderedDict() - - for folder in folders: - try: - flagged_readonly = False - if isinstance(folder, (list, tuple)): - if len(folder) == 2: - folder, flagged_readonly = folder - else: - continue - actual_readonly = not os.access(folder, os.W_OK) - - if not os.path.exists(folder): - self.logger.warn("Plugin folder {folder} could not be found, skipping it".format(folder=folder)) - continue - - for entry in scandir(folder): - try: - if entry.is_dir() and os.path.isfile(os.path.join(entry.path, "__init__.py")): - key = entry.name - elif entry.is_file() and entry.name.endswith(".py"): - key = entry.name[:-3] # strip off the .py extension - if key.startswith("__"): - # might be an __init__.py in our plugins folder, or something else we don't want - # to handle - continue - else: - continue - - if key in existing or key in result or (ignored_uninstalled and key in self.marked_plugins["uninstalled"]): - # plugin is already defined, ignore it - continue - - plugin = self._import_plugin_from_module(key, folder=folder) - if plugin: - plugin.origin = FolderOrigin("folder", folder) - plugin.managable = not flagged_readonly and not actual_readonly - plugin.bundled = flagged_readonly - - plugin.enabled = False - - result[key] = plugin - except: - self.logger.exception("Error processing folder entry {!r} from folder {}".format(entry, folder)) - except: - self.logger.exception("Error processing folder {}".format(folder)) - - return result - - def _find_plugins_from_entry_points(self, groups, existing, ignore_uninstalled=True): - result = OrderedDict() - - # let's make sure we have a current working set ... - working_set = pkg_resources.WorkingSet() - - # ... including the user's site packages - import site - import sys - if site.ENABLE_USER_SITE: - if not site.USER_SITE in working_set.entries: - working_set.add_entry(site.USER_SITE) - if not site.USER_SITE in sys.path: - site.addsitedir(site.USER_SITE) - - if not isinstance(groups, (list, tuple)): - groups = [groups] - - def wrapped(gen): - # to protect against some issues in installed packages that make iteration over entry points - # fall on its face - e.g. https://groups.google.com/forum/#!msg/octoprint/DyXdqhR0U7c/kKMUsMmIBgAJ - for entry in gen: - try: - yield entry - except: - self.logger.exception("Something went wrong while processing the entry points of a package in the " - "Python environment - broken entry_points.txt in some package?") - - for group in groups: - for entry_point in wrapped(working_set.iter_entry_points(group=group, name=None)): - try: - key = entry_point.name - module_name = entry_point.module_name - version = entry_point.dist.version - - if key in existing or key in result or (ignore_uninstalled and key in self.marked_plugins["uninstalled"]): - # plugin is already defined or marked as uninstalled, ignore it - continue - - kwargs = dict(module_name=module_name, version=version) - package_name = None - try: - module_pkginfo = InstalledEntryPoint(entry_point) - except: - self.logger.exception("Something went wrong while retrieving package info data for module %s" % module_name) - else: - kwargs.update(dict( - name=module_pkginfo.name, - summary=module_pkginfo.summary, - author=module_pkginfo.author, - url=module_pkginfo.home_page, - license=module_pkginfo.license - )) - package_name = module_pkginfo.name - - plugin = self._import_plugin_from_module(key, **kwargs) - if plugin: - plugin.origin = EntryPointOrigin("entry_point", group, module_name, package_name, version) - - # plugin is manageable if its location is writable and OctoPrint - # is either not running from a virtual env or the plugin is - # installed in that virtual env - the virtual env's pip will not - # allow us to uninstall stuff that is installed outside - # of the virtual env, so this check is necessary - plugin.managable = os.access(plugin.location, os.W_OK) \ - and (not self._python_virtual_env - or is_sub_path_of(plugin.location, self._python_prefix) - or is_editable_install(self._python_install_dir, - package_name, - module_name, - plugin.location)) - - plugin.enabled = False - result[key] = plugin - except: - self.logger.exception("Error processing entry point {!r} for group {}".format(entry_point, group)) - - return result - - def _import_plugin_from_module(self, key, folder=None, module_name=None, name=None, version=None, summary=None, author=None, url=None, license=None): - # TODO error handling - try: - if folder: - module = imp.find_module(key, [folder]) - elif module_name: - module = imp.find_module(module_name) - else: - return None - except: - self.logger.warn("Could not locate plugin {key}".format(key=key)) - return None - - if self._is_plugin_blacklisted(key) or (version is not None and self._is_plugin_version_blacklisted(key, version)): - plugin = PluginInfo(key, module[1], None, name=name, version=version, description=summary, author=author, url=url, license=license) - plugin.blacklisted = True - self.logger.warn("Plugin {} is blacklisted. Not importing it, only registering a dummy entry.".format(plugin)) - return plugin - - plugin = self._import_plugin(key, *module, name=name, version=version, summary=summary, author=author, url=url, license=license) - if plugin is None: - return None - - if plugin.check(): - return plugin - else: - self.logger.warn("Plugin \"{plugin}\" did not pass check".format(plugin=str(plugin))) - return None - - - def _import_plugin(self, key, f, filename, description, name=None, version=None, summary=None, author=None, url=None, license=None): - try: - instance = imp.load_module(key, f, filename, description) - return PluginInfo(key, filename, instance, name=name, version=version, description=summary, author=author, url=url, license=license) - except: - self.logger.exception("Error loading plugin {key}".format(key=key)) - return None - - def _is_plugin_disabled(self, key): - return key in self.plugin_disabled_list or key.endswith('disabled') - - def _is_plugin_blacklisted(self, key): - return key in self.plugin_blacklist - - def _is_plugin_version_blacklisted(self, key, version): - def matches_plugin(entry): - if isinstance(entry, (tuple, list)) and len(entry) == 2: - entry_key, entry_version = entry - return entry_key == key and entry_version == version - return False - - return any(map(lambda entry: matches_plugin(entry), - self.plugin_blacklist)) - - def reload_plugins(self, startup=False, initialize_implementations=True, force_reload=None): - self.logger.info("Loading plugins from {folders} and installed plugin packages...".format( - folders=", ".join(map(lambda x: x[0] if isinstance(x, tuple) else str(x), self.plugin_folders)) - )) - - if force_reload is None: - force_reload = [] - - plugins = self.find_plugins(existing=dict((k, v) for k, v in self.plugins.items() if not k in force_reload)) - self.disabled_plugins.update(plugins) - - # 1st pass: loading the plugins - for name, plugin in plugins.items(): - try: - if not plugin.blacklisted: - self.load_plugin(name, plugin, startup=startup, initialize_implementation=initialize_implementations) - except PluginNeedsRestart: - pass - except PluginLifecycleException as e: - self.logger.info(str(e)) - - self.on_plugins_loaded(startup=startup, - initialize_implementations=initialize_implementations, - force_reload=force_reload) - - # 2nd pass: enabling those plugins that need enabling - for name, plugin in plugins.items(): - try: - if plugin.loaded and not self._is_plugin_disabled(name): - if plugin.blacklisted: - self.logger.warn("Plugin {} is blacklisted. Not enabling it.".format(plugin)) - continue - self.enable_plugin(name, plugin=plugin, initialize_implementation=initialize_implementations, startup=startup) - except PluginNeedsRestart: - pass - except PluginLifecycleException as e: - self.logger.info(str(e)) - - self.on_plugins_enabled(startup=startup, - initialize_implementations=initialize_implementations, - force_reload=force_reload) - - if len(self.enabled_plugins) <= 0: - self.logger.info("No plugins found") - else: - self.logger.info("Found {count} plugin(s) providing {implementations} mixin implementations, {hooks} hook handlers".format( - count=len(self.enabled_plugins) + len(self.disabled_plugins), - implementations=len(self.plugin_implementations), - hooks=sum(map(lambda x: len(x), self.plugin_hooks.values())) - )) - - def mark_plugin(self, name, **kwargs): - if not name in self.plugins: - self.logger.debug("Trying to mark an unknown plugin {name}".format(**locals())) - - for key, value in kwargs.items(): - if value is None: - continue - - if value and not name in self.marked_plugins[key]: - self.marked_plugins[key].append(name) - elif not value and name in self.marked_plugins[key]: - self.marked_plugins[key].remove(name) - - def is_plugin_marked(self, name, key): - if not name in self.plugins: - return False - - return name in self.marked_plugins[key] - - def load_plugin(self, name, plugin=None, startup=False, initialize_implementation=True): - if not name in self.plugins: - self.logger.warn("Trying to load an unknown plugin {name}".format(**locals())) - return - - if plugin is None: - plugin = self.plugins[name] - - try: - if not plugin.validate("before_load", additional_validators=self.plugin_validators): - return - - plugin.load() - plugin.validate("after_load", additional_validators=self.plugin_validators) - self.on_plugin_loaded(name, plugin) - - plugin.loaded = True - - # we might only now have a version, so check again if we are blacklisted - if not plugin.blacklisted and plugin.version and self._is_plugin_version_blacklisted(plugin.key, - plugin.version): - plugin.blacklisted = True - - self.logger.debug("Loaded plugin {name}: {plugin}".format(**locals())) - except PluginLifecycleException as e: - raise e - except: - self.logger.exception("There was an error loading plugin %s" % name) - - def unload_plugin(self, name): - if not name in self.plugins: - self.logger.warn("Trying to unload unknown plugin {name}".format(**locals())) - return - - plugin = self.plugins[name] - - try: - if plugin.enabled: - self.disable_plugin(name, plugin=plugin) - - plugin.unload() - self.on_plugin_unloaded(name, plugin) - - if name in self.enabled_plugins: - del self.enabled_plugins[name] - - if name in self.disabled_plugins: - del self.disabled_plugins[name] - - plugin.loaded = False - - self.logger.debug("Unloaded plugin {name}: {plugin}".format(**locals())) - except PluginLifecycleException as e: - raise e - except: - self.logger.exception("There was an error unloading plugin {name}".format(**locals())) - - # make sure the plugin is NOT in the list of enabled plugins but in the list of disabled plugins - if name in self.enabled_plugins: - del self.enabled_plugins[name] - if not name in self.disabled_plugins: - self.disabled_plugins[name] = plugin - - def enable_plugin(self, name, plugin=None, initialize_implementation=True, startup=False): - if not name in self.disabled_plugins: - self.logger.warn("Tried to enable plugin {name}, however it is not disabled".format(**locals())) - return - - if plugin is None: - plugin = self.disabled_plugins[name] - - if not startup and self.is_restart_needing_plugin(plugin): - raise PluginNeedsRestart(name) - - if self.has_obsolete_hooks(plugin): - raise PluginCantEnable(name, "Dependency on obsolete hooks detected, full functionality cannot be guaranteed") - - try: - if not plugin.validate("before_enable", additional_validators=self.plugin_validators): - return False - - plugin.enable() - self._activate_plugin(name, plugin) - except PluginLifecycleException as e: - raise e - except: - self.logger.exception("There was an error while enabling plugin {name}".format(**locals())) - return False - else: - if name in self.disabled_plugins: - del self.disabled_plugins[name] - self.enabled_plugins[name] = plugin - plugin.enabled = True - - if plugin.implementation: - if initialize_implementation: - if not self.initialize_implementation_of_plugin(name, plugin): - return False - plugin.implementation.on_plugin_enabled() - self.on_plugin_enabled(name, plugin) - - self.logger.debug("Enabled plugin {name}: {plugin}".format(**locals())) - - return True - - def disable_plugin(self, name, plugin=None): - if not name in self.enabled_plugins: - self.logger.warn("Tried to disable plugin {name}, however it is not enabled".format(**locals())) - return - - if plugin is None: - plugin = self.enabled_plugins[name] - - if self.is_restart_needing_plugin(plugin): - raise PluginNeedsRestart(name) - - try: - plugin.disable() - self._deactivate_plugin(name, plugin) - except PluginLifecycleException as e: - raise e - except: - self.logger.exception("There was an error while disabling plugin {name}".format(**locals())) - return False - else: - if name in self.enabled_plugins: - del self.enabled_plugins[name] - self.disabled_plugins[name] = plugin - plugin.enabled = False - - if plugin.implementation: - plugin.implementation.on_plugin_disabled() - self.on_plugin_disabled(name, plugin) - - self.logger.debug("Disabled plugin {name}: {plugin}".format(**locals())) - - return True - - def _activate_plugin(self, name, plugin): - plugin.hotchangeable = self.is_restart_needing_plugin(plugin) - - # evaluate registered hooks - for hook, definition in plugin.hooks.items(): - try: - callback, order = self._get_callback_and_order(definition) - except ValueError as e: - self.logger.warn("There is something wrong with the hook definition {} for plugin {}: {}".format(definition, name, str(e))) - continue - - self._plugin_hooks[hook].append((order, name, callback)) - self._sort_hooks(hook) - - # evaluate registered implementation - if plugin.implementation: - mixins = self.mixins_matching_bases(plugin.implementation.__class__, *self.plugin_bases) - for mixin in mixins: - self.plugin_implementations_by_type[mixin].append((name, plugin.implementation)) - - self.plugin_implementations[name] = plugin.implementation - - def _deactivate_plugin(self, name, plugin): - for hook, definition in plugin.hooks.items(): - try: - callback, order = self._get_callback_and_order(definition) - except ValueError as e: - self.logger.warn("There is something wrong with the hook definition {} for plugin {}: {}".format(definition, name, str(e))) - continue - - try: - self._plugin_hooks[hook].remove((order, name, callback)) - self._sort_hooks(hook) - except ValueError: - # that's ok, the plugin was just not registered for the hook - pass - - if plugin.implementation is not None: - if name in self.plugin_implementations: - del self.plugin_implementations[name] - - mixins = self.mixins_matching_bases(plugin.implementation.__class__, *self.plugin_bases) - for mixin in mixins: - try: - self.plugin_implementations_by_type[mixin].remove((name, plugin.implementation)) - except ValueError: - # that's ok, the plugin was just not registered for the type - pass - - def is_restart_needing_plugin(self, plugin): - return plugin.needs_restart or self.has_restart_needing_implementation(plugin) or self.has_restart_needing_hooks(plugin) - - def has_restart_needing_implementation(self, plugin): - return self.has_any_of_mixins(plugin, RestartNeedingPlugin) - - def has_restart_needing_hooks(self, plugin): - return self.has_any_of_hooks(plugin, self.plugin_restart_needing_hooks) - - def has_obsolete_hooks(self, plugin): - return self.has_any_of_hooks(plugin, self.plugin_obsolete_hooks) - - def is_restart_needing_hook(self, hook): - return self.hook_matches_hooks(hook, self.plugin_restart_needing_hooks) - - def is_obsolete_hook(self, hook): - return self.hook_matches_hooks(hook, self.plugin_obsolete_hooks) - - @staticmethod - def has_any_of_hooks(plugin, *hooks): - """ - Tests if the ``plugin`` contains any of the provided ``hooks``. - - Uses :func:`octoprint.plugin.core.PluginManager.hook_matches_hooks`. - - Args: - plugin: plugin to test hooks for - *hooks: hooks to test against - - Returns: - (bool): True if any of the plugin's hooks match the provided hooks, - False otherwise. - """ - - if hooks and len(hooks) == 1 and isinstance(hooks[0], (list, tuple)): - hooks = hooks[0] - - hooks = filter(lambda hook: hook is not None, hooks) - if not hooks: - return False - if not plugin or not plugin.hooks: - return False - - plugin_hooks = plugin.hooks.keys() - - return any(map(lambda hook: PluginManager.hook_matches_hooks(hook, *hooks), - plugin_hooks)) - - @staticmethod - def hook_matches_hooks(hook, *hooks): - """ - Tests if ``hook`` matches any of the provided ``hooks`` to test for. - - ``hook`` is expected to be an exact hook name. - - ``hooks`` is expected to be a list containing one or more hook names or - patterns. That can be either an exact hook name or an - :func:`fnmatch.fnmatch` pattern. - - Args: - hook: the hook to test - hooks: the hook name patterns to test against - - Returns: - (bool): True if the ``hook`` matches any of the ``hooks``, False otherwise. - - """ - - if hooks and len(hooks) == 1 and isinstance(hooks[0], (list, tuple)): - hooks = hooks[0] - - hooks = filter(lambda hook: hook is not None, hooks) - if not hooks: - return False - if not hook: - return False - - return any(map(lambda h: fnmatch.fnmatch(hook, h), - hooks)) - - @staticmethod - def mixins_matching_bases(klass, *bases): - result = set() - for c in inspect.getmro(klass): - if c == klass or c in bases: - # ignore the exact class and our bases - continue - if issubclass(c, bases): - result.add(c) - return result - - @staticmethod - def has_any_of_mixins(plugin, *mixins): - """ - Tests if the ``plugin`` has an implementation implementing any - of the provided ``mixins``. - - Args: - plugin: plugin for which to check the implementation - *mixins: mixins to test against - - Returns: - (bool): True if the plugin's implementation implements any of the - provided mixins, False otherwise. - """ - - if mixins and len(mixins) == 1 and isinstance(mixins[0], (list, tuple)): - mixins = mixins[0] - - mixins = filter(lambda mixin: mixin is not None, mixins) - if not mixins: - return False - if not plugin or not plugin.implementation: - return False - - return isinstance(plugin.implementation, tuple(mixins)) - - def initialize_implementations(self, additional_injects=None, additional_inject_factories=None, additional_pre_inits=None, additional_post_inits=None): - for name, plugin in self.enabled_plugins.items(): - self.initialize_implementation_of_plugin(name, plugin, - additional_injects=additional_injects, - additional_inject_factories=additional_inject_factories, - additional_pre_inits=additional_pre_inits, - additional_post_inits=additional_post_inits) - - self.logger.info("Initialized {count} plugin implementation(s)".format(count=len(self.plugin_implementations))) - - def initialize_implementation_of_plugin(self, name, plugin, additional_injects=None, additional_inject_factories=None, additional_pre_inits=None, additional_post_inits=None): - if plugin.implementation is None: - return - - return self.initialize_implementation(name, plugin, plugin.implementation, - additional_injects=additional_injects, - additional_inject_factories=additional_inject_factories, - additional_pre_inits=additional_pre_inits, - additional_post_inits=additional_post_inits) - - def initialize_implementation(self, name, plugin, implementation, additional_injects=None, additional_inject_factories=None, additional_pre_inits=None, additional_post_inits=None): - if additional_injects is None: - additional_injects = dict() - if additional_inject_factories is None: - additional_inject_factories = [] - if additional_pre_inits is None: - additional_pre_inits = [] - if additional_post_inits is None: - additional_post_inits = [] - - injects = self.implementation_injects - injects.update(additional_injects) - - inject_factories = self.implementation_inject_factories - inject_factories += additional_inject_factories - - pre_inits = self.implementation_pre_inits - pre_inits += additional_pre_inits - - post_inits = self.implementation_post_inits - post_inits += additional_post_inits - - try: - kwargs = dict(injects) - - kwargs.update(dict( - identifier=name, - plugin_name=plugin.name, - plugin_version=plugin.version, - plugin_info=plugin, - basefolder=os.path.realpath(plugin.location), - logger=logging.getLogger(self.logging_prefix + name), - )) - - # inject the additional_injects - for arg, value in kwargs.items(): - setattr(implementation, "_" + arg, value) - - # inject any injects produced in the additional_inject_factories - for factory in inject_factories: - try: - return_value = factory(name, implementation) - except: - self.logger.exception("Exception while executing injection factory %r" % factory) - else: - if return_value is not None: - if isinstance(return_value, dict): - for arg, value in return_value.items(): - setattr(implementation, "_" + arg, value) - - # execute any additional pre init methods - for pre_init in pre_inits: - pre_init(name, implementation) - - implementation.initialize() - - # execute any additional post init methods - for post_init in post_inits: - post_init(name, implementation) - - except Exception as e: - self._deactivate_plugin(name, plugin) - plugin.enabled = False - - if isinstance(e, PluginLifecycleException): - raise e - else: - self.logger.exception("Exception while initializing plugin {name}, disabling it".format(**locals())) - return False - else: - self.on_plugin_implementations_initialized(name, plugin) - - self.logger.debug("Initialized plugin mixin implementation for plugin {name}".format(**locals())) - return True - - - def log_all_plugins(self, show_bundled=True, bundled_str=(" (bundled)", ""), show_location=True, - location_str=" = {location}", show_enabled=True, enabled_str=(" ", "!", "#"), - only_to_handler=None): - all_plugins = self.enabled_plugins.values() + self.disabled_plugins.values() - - def _log(message, level=logging.INFO): - if only_to_handler is not None: - import octoprint.logging - octoprint.logging.log_to_handler(self.logger, only_to_handler, level, message, []) - else: - self.logger.log(level, message) - - if len(all_plugins) <= 0: - _log("No plugins available") - else: - formatted_plugins = "\n".join(map(lambda x: "| " + x.long_str(show_bundled=show_bundled, - bundled_strs=bundled_str, - show_location=show_location, - location_str=location_str, - show_enabled=show_enabled, - enabled_strs=enabled_str), - sorted(self.plugins.values(), key=lambda x: str(x).lower()))) - _log("{count} plugin(s) registered with the system:\n{plugins}".format(count=len(all_plugins), - plugins=formatted_plugins)) - - def get_plugin(self, identifier, require_enabled=True): - """ - Retrieves the module of the plugin identified by ``identifier``. If the plugin is not registered or disabled and - ``required_enabled`` is True (the default) None will be returned. - - Arguments: - identifier (str): The identifier of the plugin to retrieve. - require_enabled (boolean): Whether to only return the plugin if is enabled (True, default) or also if it's - disabled. - - Returns: - module: The requested plugin module or None - """ - - plugin_info = self.get_plugin_info(identifier, require_enabled=require_enabled) - if plugin_info is not None: - return plugin_info.instance - return None - - def get_plugin_info(self, identifier, require_enabled=True): - """ - Retrieves the :class:`PluginInfo` instance identified by ``identifier``. If the plugin is not registered or - disabled and ``required_enabled`` is True (the default) None will be returned. - - Arguments: - identifier (str): The identifier of the plugin to retrieve. - require_enabled (boolean): Whether to only return the plugin if is enabled (True, default) or also if it's - disabled. - - Returns: - ~.PluginInfo: The requested :class:`PluginInfo` or None - """ - - if identifier in self.enabled_plugins: - return self.enabled_plugins[identifier] - elif not require_enabled and identifier in self.disabled_plugins: - return self.disabled_plugins[identifier] - - return None - - def get_hooks(self, hook): - """ - Retrieves all registered handlers for the specified hook. - - Arguments: - hook (str): The hook for which to retrieve the handlers. - - Returns: - dict: A dict containing all registered handlers mapped by their plugin's identifier. - """ - - if not hook in self.plugin_hooks: - return dict() - - result = OrderedDict() - for h in self.plugin_hooks[hook]: - result[h[0]] = h[1] - return result - - def get_implementations(self, *types, **kwargs): - """ - Get all mixin implementations that implement *all* of the provided ``types``. - - Arguments: - types (one or more type): The types a mixin implementation needs to implement in order to be returned. - - Returns: - list: A list of all found implementations - """ - - sorting_context = kwargs.get("sorting_context", None) - - result = None - - for t in types: - implementations = self.plugin_implementations_by_type[t] - if result is None: - result = set(implementations) - else: - result = result.intersection(implementations) - - if result is None: - return [] - - def sort_func(impl): - sorting_value = None - if sorting_context is not None and isinstance(impl[1], SortablePlugin): - try: - sorting_value = impl[1].get_sorting_key(sorting_context) - except: - self.logger.exception("Error while trying to retrieve sorting order for plugin {}".format(impl[0])) - - if sorting_value is not None: - try: - int(sorting_value) - except ValueError: - self.logger.warn("The order value returned by {} for sorting context {} is not a valid integer, ignoring it".format(impl[0], sorting_context)) - sorting_value = None - - return sorting_value is None, sorting_value, impl[0] - - return [impl[1] for impl in sorted(result, key=sort_func)] - def get_filtered_implementations(self, f, *types, **kwargs): - """ - Get all mixin implementations that implement *all* of the provided ``types`` and match the provided filter `f`. +class EntryPointOrigin(_EntryPointOrigin): + """ + Origin of a plugin registered via an entry point. - Arguments: - f (callable): A filter function returning True for implementations to return and False for those to exclude. - types (one or more type): The types a mixin implementation needs to implement in order to be returned. + .. attribute:: type - Returns: - list: A list of all found and matching implementations. - """ + Always ``entry_point``. - assert callable(f) - implementations = self.get_implementations(*types, sorting_context=kwargs.get("sorting_context", None)) - return filter(f, implementations) + .. attribute:: entry_point - def get_helpers(self, name, *helpers): - """ - Retrieves the named ``helpers`` for the plugin with identifier ``name``. + Name of the entry point, usually ``octoprint.plugin``. - If the plugin is not available, returns None. Otherwise returns a :class:`dict` with the requested plugin - helper names mapped to the method - if a helper could not be resolved, it will be missing from the dict. + .. attribute:: module_name - Arguments: - name (str): Identifier of the plugin for which to look up the ``helpers``. - helpers (one or more str): Identifiers of the helpers of plugin ``name`` to return. + Module registered to the entry point. - Returns: - dict: A dictionary of all resolved helpers, mapped by their identifiers, or None if the plugin was not - registered with the system. - """ + .. attribute:: package_name - if not name in self.enabled_plugins: - return None - plugin = self.enabled_plugins[name] + Python package containing the entry point. - all_helpers = plugin.helpers - if len(helpers): - return dict((k, v) for (k, v) in all_helpers.items() if k in helpers) - else: - return all_helpers + .. attribute:: package_version - def register_message_receiver(self, client): - """ - Registers a ``client`` for receiving plugin messages. The ``client`` needs to be a callable accepting two - input arguments, ``plugin`` (the sending plugin's identifier) and ``data`` (the message itself). - """ + Version of the python package containing the entry point. + """ - if client is None: - return - self.registered_clients.append(client) - def unregister_message_receiver(self, client): - """ - Unregisters a ``client`` for receiving plugin messages. - """ +_FolderOrigin = namedtuple("FolderOrigin", "type, folder") - self.registered_clients.remove(client) - def send_plugin_message(self, plugin, data): - """ - Sends ``data`` in the name of ``plugin`` to all currently registered message receivers by invoking them - with the two arguments. +class FolderOrigin(_FolderOrigin): + """ + Origin of a (single file) plugin loaded from a plugin folder. - Arguments: - plugin (str): The sending plugin's identifier. - data (object): The message. - """ + .. attribute:: type - for client in self.registered_clients: - try: client(plugin, data) - except: self.logger.exception("Exception while sending plugin data to client") - - def _sort_hooks(self, hook): - self._plugin_hooks[hook] = sorted(self._plugin_hooks[hook], - key=lambda x: (x[0] is None, x[0], x[1], x[2])) + Always `folder`. - def _get_callback_and_order(self, hook): - if callable(hook): - return hook, None + .. attribute:: folder - elif isinstance(hook, tuple) and len(hook) == 2: - callback, order = hook + Folder path from which the plugin was loaded. + """ - # test that callback is a callable - if not callable(callback): - raise ValueError("Hook callback is not a callable") - # test that number is an int - try: - int(order) - except ValueError: - raise ValueError("Hook order is not a number") +_ModuleOrigin = namedtuple("ModuleOrigin", "type, module_name, folder") - return callback, order - else: - raise ValueError("Invalid hook definition, neither a callable nor a 2-tuple (callback, order): {!r}".format(hook)) +class ModuleOrigin(_ModuleOrigin): + """ + Origin of a (single file) plugin loaded from a plugin folder. + + .. attribute:: type + + Always `module`. + + .. attribute:: module_name + + Name of the module from which the plugin was loaded. + + .. attribute:: folder + + Folder path from which the plugin was loaded. + """ + + +class PluginInfo(object): + """ + The :class:`PluginInfo` class wraps all available information about a registered plugin. + + This includes its meta data (like name, description, version, etc) as well as the actual plugin extensions like + implementations, hooks and helpers. + + It works on Python module objects and extracts the relevant data from those via accessing the + :ref:`control properties `. + + Arguments: + key (str): Identifier of the plugin + location (str): Installation folder of the plugin + instance (module): Plugin module instance - this may be ``None`` if the plugin has been blacklisted! + name (str): Human readable name of the plugin + version (str): Version of the plugin + description (str): Description of the plugin + author (str): Author of the plugin + url (str): URL of the website of the plugin + license (str): License of the plugin + """ + + def __init__( + self, + key, + location, + instance, + name=None, + version=None, + description=None, + author=None, + url=None, + license=None, + parsed_metadata=None, + ): + self.key = key + self.location = location + self.instance = instance + + self.origin = None + """ + The origin from which this plugin was loaded, either a :class:`EntryPointOrigin`, :class:`FolderOrigin` + or :class:`ModuleOrigin` instance. Set during loading, initially ``None``. + """ + + self.enabled = True + """Whether the plugin is enabled.""" + + self.blacklisted = False + """Whether the plugin is blacklisted.""" + + self.forced_disabled = False + """Whether the plugin has been force disabled by the system, e.g. due to safe mode blacklisting.""" + + self.incompatible = False + """Whether this plugin has been detected as incompatible.""" + + self.bundled = False + """Whether this plugin is bundled with OctoPrint.""" + + self.loaded = False + """Whether this plugin has been loaded.""" + + self.managable = True + """Whether this plugin can be managed by OctoPrint.""" + + self.needs_restart = False + """Whether this plugin needs a restart of OctoPrint after enabling/disabling.""" + + self.invalid_syntax = False + """Whether invalid syntax was encountered while trying to load this plugin.""" + + self._name = name + self._version = version + self._description = description + self._author = author + self._url = url + self._license = license + + self._logger = logging.getLogger(__name__) + + self._cached_parsed_metadata = parsed_metadata + if self._cached_parsed_metadata is None: + self._cached_parsed_metadata = self._parse_metadata() + + def validate(self, phase, additional_validators=None): + """ + Validates the plugin for various validation phases. + + ``phase`` can be one of ``before_import``, ``before_load``, ``after_load``. + + Used by :class:`PluginManager`, should not be used elsewhere. + """ + result = True + + if phase == "before_import": + result = ( + self.looks_like_plugin + and not self.forced_disabled + and not self.blacklisted + and not self.incompatible + and not self.invalid_syntax + and result + ) + + elif phase == "before_load": + pass + + elif phase == "after_load": + pass + + if additional_validators is not None: + for validator in additional_validators: + result = validator(phase, self) and result + + return result + + def __str__(self): + if self.version: + return "{name} ({version})".format(name=self.name, version=self.version) + else: + return to_unicode(self.name) + + def long_str( + self, + show_bundled=False, + bundled_strs=(" [B]", ""), + show_location=False, + location_str=" - {location}", + show_enabled=False, + enabled_strs=("* ", " ", "X ", "C "), + ): + """ + Long string representation of the plugin's information. Will return a string of the format ````. + + ``enabled``, ``bundled`` and ``location`` will only be displayed if the corresponding flags are set to ``True``. + The will be filled from ``enabled_str``, ``bundled_str`` and ``location_str`` as follows: + + ``enabled_str`` + a 4-tuple, the first entry being the string to insert when the plugin is enabled, the second + entry the string to insert when it is not, the third entry the string when it is blacklisted + and the fourth when it is incompatible. + ``bundled_str`` + a 2-tuple, the first entry being the string to insert when the plugin is bundled, the second + entry the string to insert when it is not. + ``location_str`` + a format string (to be parsed with ``str.format``), the ``{location}`` placeholder will be + replaced with the plugin's installation folder on disk. + + Arguments: + show_enabled (boolean): whether to show the ``enabled`` part + enabled_strs (tuple): the 2-tuple containing the two possible strings to use for displaying the enabled state + show_bundled (boolean): whether to show the ``bundled`` part + bundled_strs(tuple): the 2-tuple containing the two possible strings to use for displaying the bundled state + show_location (boolean): whether to show the ``location`` part + location_str (str): the format string to use for displaying the plugin's installation location + + Returns: + str: The long string representation of the plugin as described above + """ + if show_enabled: + if self.incompatible: + ret = to_unicode(enabled_strs[3]) + elif self.blacklisted: + ret = to_unicode(enabled_strs[2]) + elif not self.enabled: + ret = to_unicode(enabled_strs[1]) + else: + ret = to_unicode(enabled_strs[0]) + else: + ret = "" + + ret += str(self) + + if show_bundled: + ret += ( + to_unicode(bundled_strs[0]) + if self.bundled + else to_unicode(bundled_strs[1]) + ) + + if show_location and self.location: + ret += to_unicode(location_str).format(location=self.location) + + return ret + + def get_hook(self, hook): + """ + Arguments: + hook (str): Hook to return. + + Returns: + callable or None: Handler for the requested ``hook`` or None if no handler is registered. + """ + + if hook not in self.hooks: + return None + return self.hooks[hook] + + def get_implementation(self, *types): + """ + Arguments: + types (list): List of :class:`Plugin` sub classes the implementation needs to implement. + + Returns: + object: The plugin's implementation if it matches all of the requested ``types``, None otherwise. + """ + + if self.implementation and all( + map(lambda t: isinstance(self.implementation, t), types) + ): + return self.implementation + else: + return None + + @property + def name(self): + """ + Human readable name of the plugin. Will be taken from name attribute of the plugin module if available, + otherwise from the ``name`` supplied during construction with a fallback to ``key``. + + Returns: + str: Name of the plugin, fallback is the plugin's identifier. + """ + return self._get_instance_attribute( + ControlProperties.attr_name, + defaults=(self._name, self.key), + incl_metadata=True, + ) + + @property + def description(self): + """ + Description of the plugin. Will be taken from the description attribute of the plugin module as defined in + :attr:`attr_description` if available, otherwise from the ``description`` supplied during construction. + May be None. + + Returns: + str or None: Description of the plugin. + """ + return self._get_instance_attribute( + ControlProperties.attr_description, + default=self._description, + incl_metadata=True, + ) + + @property + def disabling_discouraged(self): + """ + Reason why disabling of this plugin is discouraged. Only evaluated for bundled plugins! Will be taken from + the disabling_discouraged attribute of the plugin module as defined in :attr:`attr_disabling_discouraged` if + available. False if unset or plugin not bundled. + + Returns: + str or None: Reason why disabling this plugin is discouraged (only for bundled plugins) + """ + return ( + self._get_instance_attribute( + ControlProperties.attr_disabling_DISCOURAGED, default=False + ) + if self.bundled + else False + ) + + @property + def version(self): + """ + Version of the plugin. Will be taken from the version attribute of the plugin module as defined in + :attr:`attr_version` if available, otherwise from the ``version`` supplied during construction. May be None. + + Returns: + str or None: Version of the plugin. + """ + return ( + self._version + if self._version is not None + else self._get_instance_attribute( + ControlProperties.attr_version, default=self._version, incl_metadata=True + ) + ) + + @property + def author(self): + """ + Author of the plugin. Will be taken from the author attribute of the plugin module as defined in + :attr:`attr_author` if available, otherwise from the ``author`` supplied during construction. May be None. + + Returns: + str or None: Author of the plugin. + """ + return self._get_instance_attribute( + ControlProperties.attr_author, default=self._author, incl_metadata=True + ) + + @property + def url(self): + """ + Website URL for the plugin. Will be taken from the url attribute of the plugin module as defined in + :attr:`attr_url` if available, otherwise from the ``url`` supplied during construction. May be None. + + Returns: + str or None: Website URL for the plugin. + """ + return self._get_instance_attribute( + ControlProperties.attr_url, default=self._url, incl_metadata=True + ) + + @property + def license(self): + """ + License of the plugin. Will be taken from the license attribute of the plugin module as defined in + :attr:`attr_license` if available, otherwise from the ``license`` supplied during construction. May be None. + + Returns: + str or None: License of the plugin. + """ + return self._get_instance_attribute( + ControlProperties.attr_license, default=self._license, incl_metadata=True + ) + + @property + def pythoncompat(self): + """ + Python compatibility string of the plugin module as defined in :attr:`attr_pythoncompat` if available, otherwise + defaults to ``>=2.7,<3``. + + Returns: + str: Python compatibility string of the plugin + """ + return self._get_instance_attribute( + ControlProperties.attr_pythoncompat, default=">=2.7,<3", incl_metadata=True + ) + + @property + def hidden(self): + """ + Hidden flag. + + Returns: + bool: Whether the plugin should be flagged as hidden or not + """ + return self._get_instance_attribute( + ControlProperties.attr_hidden, default=False, incl_metadata=True + ) + + @property + def hooks(self): + """ + Hooks provided by the plugin. Will be taken from the hooks attribute of the plugin module as defined in + :attr:`attr_hooks` if available, otherwise an empty dictionary is returned. + + Returns: + dict: Hooks provided by the plugin. + """ + return self._get_instance_attribute(ControlProperties.attr_hooks, default={}) + + @property + def implementation(self): + """ + Implementation provided by the plugin. Will be taken from the implementation attribute of the plugin module + as defined in :attr:`attr_implementation` if available, otherwise None is returned. + + Returns: + object: Implementation provided by the plugin. + """ + return self._get_instance_attribute( + ControlProperties.attr_implementation, default=None + ) + + @property + def helpers(self): + """ + Helpers provided by the plugin. Will be taken from the helpers attribute of the plugin module as defined in + :attr:`attr_helpers` if available, otherwise an empty list is returned. + + Returns: + dict: Helpers provided by the plugin. + """ + return self._get_instance_attribute(ControlProperties.attr_helpers, default={}) + + @property + def check(self): + """ + Method for pre-load check of plugin. Will be taken from the check attribute of the plugin module as defined in + :attr:`attr_check` if available, otherwise a lambda always returning True is returned. + + Returns: + callable: Check method for the plugin module which should return True if the plugin can be loaded, False + otherwise. + """ + return self._get_instance_attribute( + ControlProperties.attr_check, default=lambda: True + ) + + @property + def load(self): + """ + Method for loading the plugin module. Will be taken from the load attribute of the plugin module as defined + in :attr:`attr_load` if available, otherwise a no-operation lambda will be returned. + + Returns: + callable: Load method for the plugin module. + """ + return self._get_instance_attribute( + ControlProperties.attr_load, default=lambda: True + ) + + @property + def unload(self): + """ + Method for unloading the plugin module. Will be taken from the unload attribute of the plugin module as defined + in :attr:`attr_unload` if available, otherwise a no-operation lambda will be returned. + + Returns: + callable: Unload method for the plugin module. + """ + return self._get_instance_attribute( + ControlProperties.attr_unload, default=lambda: True + ) + + @property + def enable(self): + """ + Method for enabling the plugin module. Will be taken from the enable attribute of the plugin module as defined + in :attr:`attr_enable` if available, otherwise a no-operation lambda will be returned. + + Returns: + callable: Enable method for the plugin module. + """ + return self._get_instance_attribute( + ControlProperties.attr_enable, default=lambda: True + ) + + @property + def disable(self): + """ + Method for disabling the plugin module. Will be taken from the disable attribute of the plugin module as defined + in :attr:`attr_disable` if available, otherwise a no-operation lambda will be returned. + + Returns: + callable: Disable method for the plugin module. + """ + return self._get_instance_attribute( + ControlProperties.attr_disable, default=lambda: True + ) + + def _get_instance_attribute( + self, attr, default=None, defaults=None, incl_metadata=False + ): + if self.instance is None or not hasattr(self.instance, attr): + if incl_metadata and attr in self.parsed_metadata: + return self.parsed_metadata[attr] + + elif defaults is not None: + for value in defaults: + if callable(value): + value = value() + if value is not None: + return value + + return default + + return getattr(self.instance, attr) + + @property + def parsed_metadata(self): + """The plugin metadata parsed from the plugin's AST.""" + return self._cached_parsed_metadata + + @property + def control_properties(self): + return ControlProperties.all() + + @property + def looks_like_plugin(self): + """ + Returns whether the plugin actually looks like a plugin (has control properties) or not. + """ + return self.parsed_metadata.get("has_control_properties", False) + + def _parse_metadata(self): + try: + return parse_plugin_metadata(self.location) + except SyntaxError: + self._logger.exception( + "Invalid syntax in plugin file of plugin {}".format(self.key) + ) + self.invalid_syntax = True + return {} + + +class PluginManager(object): + """ + The :class:`PluginManager` is the central component for finding, loading and accessing plugins provided to the + system. + + It is able to discover plugins both through possible file system locations as well as customizable entry points. + """ + + plugin_timings_logtarget = "PLUGIN_TIMINGS" + plugin_timings_message = "{func} - {timing:05.2f}ms" + + def __init__( + self, + plugin_folders, + plugin_bases, + plugin_entry_points, + logging_prefix=None, + plugin_disabled_list=None, + plugin_blacklist=None, + plugin_restart_needing_hooks=None, + plugin_obsolete_hooks=None, + plugin_considered_bundled=None, + plugin_validators=None, + compatibility_ignored_list=None, + ): + self.logger = logging.getLogger(__name__) + + if logging_prefix is None: + logging_prefix = "" + if plugin_folders is None: + plugin_folders = [] + if plugin_bases is None: + plugin_bases = [] + if plugin_entry_points is None: + plugin_entry_points = [] + if plugin_disabled_list is None: + plugin_disabled_list = [] + if plugin_blacklist is None: + plugin_blacklist = [] + if compatibility_ignored_list is None: + compatibility_ignored_list = [] + if plugin_considered_bundled is None: + plugin_considered_bundled = [] + + processed_blacklist = [] + for entry in plugin_blacklist: + if isinstance(entry, (tuple, list)): + key, version = entry + try: + processed_blacklist.append((key, Requirement(key + version))) + except Exception: + self.logger.warning( + "Invalid version requirement {} for blacklist " + "entry {}, ignoring".format(version, key) + ) + else: + processed_blacklist.append(entry) + + self.plugin_folders = plugin_folders + self.plugin_bases = plugin_bases + self.plugin_entry_points = plugin_entry_points + self.plugin_disabled_list = plugin_disabled_list + self.plugin_blacklist = processed_blacklist + self.plugin_restart_needing_hooks = plugin_restart_needing_hooks + self.plugin_obsolete_hooks = plugin_obsolete_hooks + self.plugin_validators = plugin_validators + self.logging_prefix = logging_prefix + self.compatibility_ignored_list = compatibility_ignored_list + self.plugin_considered_bundled = plugin_considered_bundled + + self.enabled_plugins = {} + self.disabled_plugins = {} + self.plugin_implementations = {} + self.plugin_implementations_by_type = defaultdict(list) + + self._plugin_hooks = defaultdict(list) + + self.implementation_injects = {} + self.implementation_inject_factories = [] + self.implementation_pre_inits = [] + self.implementation_post_inits = [] + + self.on_plugin_loaded = lambda *args, **kwargs: None + self.on_plugin_unloaded = lambda *args, **kwargs: None + self.on_plugin_enabled = lambda *args, **kwargs: None + self.on_plugin_disabled = lambda *args, **kwargs: None + self.on_plugin_implementations_initialized = lambda *args, **kwargs: None + + self.on_plugins_loaded = lambda *args, **kwargs: None + self.on_plugins_enabled = lambda *args, **kwargs: None + + self.registered_clients = [] + + self.marked_plugins = defaultdict(list) + + self._python_install_dir = None + self._python_virtual_env = False + self._detect_python_environment() + + def _detect_python_environment(self): + import sys + from distutils.command.install import install as cmd_install + from distutils.dist import Distribution + + cmd = cmd_install(Distribution()) + cmd.finalize_options() + + self._python_install_dir = cmd.install_lib + self._python_prefix = os.path.realpath(sys.prefix) + self._python_virtual_env = hasattr(sys, "real_prefix") or ( + hasattr(sys, "base_prefix") + and os.path.realpath(sys.prefix) != os.path.realpath(sys.base_prefix) + ) + + @property + def plugins(self): + """ + Returns: + (list) list of enabled and disabled registered plugins + """ + plugins = dict(self.enabled_plugins) + plugins.update(self.disabled_plugins) + return plugins + + @property + def plugin_hooks(self): + """ + Returns: + (dict) dictionary of registered hooks and their handlers + """ + return { + key: list(map(lambda v: (v[1], v[2]), value)) + for key, value in self._plugin_hooks.items() + } + + def find_plugins(self, existing=None, ignore_uninstalled=True, incl_all_found=False): + added, found = self._find_plugins( + existing=existing, ignore_uninstalled=ignore_uninstalled + ) + if incl_all_found: + return added, found + else: + return added + + def _find_plugins(self, existing=None, ignore_uninstalled=True): + if existing is None: + existing = dict(self.plugins) + + result_added = OrderedDict() + result_found = [] + + if self.plugin_folders: + try: + added, found = self._find_plugins_from_folders( + self.plugin_folders, existing, ignored_uninstalled=ignore_uninstalled + ) + result_added.update(added) + result_found += found + except Exception: + self.logger.exception("Error fetching plugins from folders") + + if self.plugin_entry_points: + existing.update(result_added) + try: + added, found = self._find_plugins_from_entry_points( + self.plugin_entry_points, + existing, + ignore_uninstalled=ignore_uninstalled, + ) + result_added.update(added) + result_found += found + except Exception: + self.logger.exception("Error fetching plugins from entry points") + + return result_added, result_found + + def _find_plugins_from_folders(self, folders, existing, ignored_uninstalled=True): + added = OrderedDict() + found = [] + + for folder in folders: + try: + flagged_readonly = False + package = None + if isinstance(folder, (list, tuple)): + if len(folder) == 2: + folder, flagged_readonly = folder + elif len(folder) == 3: + folder, package, flagged_readonly = folder + else: + continue + actual_readonly = not os.access(folder, os.W_OK) + + if not os.path.exists(folder): + self.logger.warning( + "Plugin folder {folder} could not be found, skipping it".format( + folder=folder + ) + ) + continue + + for entry in scandir(folder): + try: + if entry.is_dir(): + init_py = os.path.join(entry.path, "__init__.py") + + if not os.path.isfile(init_py): + # neither does exist, we ignore this + continue + + key = entry.name + + elif entry.is_file(): + key, ext = os.path.splitext(entry.name) + if ext not in (".py",) or key.startswith("__"): + # not py, or starts with __ (like __init__), we ignore this + continue + + else: + # whatever this is, we ignore it + continue + + found.append(key) + if ( + key in existing + or key in added + or ( + ignored_uninstalled + and key in self.marked_plugins["uninstalled"] + ) + ): + # plugin is already defined, ignore it + continue + + bundled = flagged_readonly + + module_name = None + if package: + module_name = "{}.{}".format(package, key) + + plugin = self._import_plugin_from_module( + key, module_name=module_name, folder=folder, bundled=bundled + ) + if plugin: + if module_name: + plugin.origin = ModuleOrigin( + "module", module_name, folder + ) + else: + plugin.origin = FolderOrigin("folder", folder) + plugin.managable = ( + not flagged_readonly and not actual_readonly + ) + plugin.enabled = False + added[key] = plugin + except Exception: + self.logger.exception( + "Error processing folder entry {!r} from folder {}".format( + entry, folder + ) + ) + except Exception: + self.logger.exception("Error processing folder {}".format(folder)) + + return added, found + + def _find_plugins_from_entry_points(self, groups, existing, ignore_uninstalled=True): + added = OrderedDict() + found = [] + + # make sure user site packages are on sys.path so importlib metadata can see them + import site + import sys + + if site.ENABLE_USER_SITE: + if site.USER_SITE not in sys.path: + site.addsitedir(site.USER_SITE) + + if not isinstance(groups, (list, tuple)): + groups = [groups] + + def wrapped(gen): + # to protect against some issues in installed packages that make iteration over entry points + # fall on its face - e.g. https://groups.google.com/forum/#!msg/octoprint/DyXdqhR0U7c/kKMUsMmIBgAJ + for entry in gen: + try: + yield entry + except Exception: + self.logger.exception( + "Something went wrong while processing the entry points of a package in the " + "Python environment - broken entry_points.txt in some package?" + ) + + for group in groups: + for entry_point in wrapped(entry_points(group=group)): + try: + key = entry_point.name + module_name = ( + entry_point.module + if hasattr(entry_point, "module") + else entry_point.module_name + ) + version = entry_point.dist.version + + found.append(key) + if ( + key in existing + or key in added + or ( + ignore_uninstalled + and key in self.marked_plugins["uninstalled"] + ) + ): + # plugin is already defined or marked as uninstalled, ignore it + continue + + bundled = key in self.plugin_considered_bundled + kwargs = { + "module_name": module_name, + "version": version, + "bundled": bundled, + } + package_name = entry_point.dist.name + try: + entry_point_metadata = EntryPointMetadata(entry_point) + except Exception: + self.logger.exception( + "Something went wrong while retrieving metadata for module {}".format( + module_name + ) + ) + else: + kwargs.update( + { + "name": entry_point_metadata.name, + "summary": entry_point_metadata.summary, + "author": entry_point_metadata.author, + "url": entry_point_metadata.home_page, + "license": entry_point_metadata.license, + } + ) + + plugin = self._import_plugin_from_module(key, **kwargs) + if plugin: + plugin.origin = EntryPointOrigin( + "entry_point", group, module_name, package_name, version + ) + plugin.enabled = False + + # plugin is manageable if its location is writable and OctoPrint + # is either not running from a virtual env or the plugin is + # installed in that virtual env - the virtual env's pip will not + # allow us to uninstall stuff that is installed outside + # of the virtual env, so this check is necessary + plugin.managable = os.access(plugin.location, os.W_OK) and ( + not self._python_virtual_env + or is_sub_path_of(plugin.location, self._python_prefix) + or is_editable_install( + self._python_install_dir, + package_name, + module_name, + plugin.location, + ) + ) + + added[key] = plugin + except Exception: + self.logger.exception( + "Error processing entry point {!r} for group {}".format( + entry_point, group + ) + ) + + return added, found + + def _import_plugin_from_module( + self, + key, + folder=None, + module_name=None, + name=None, + version=None, + summary=None, + author=None, + url=None, + license=None, + bundled=False, + ): + # TODO error handling + try: + if folder: + location, spec = _find_module(key, path=folder) + elif module_name: + location, spec = _find_module(module_name) + else: + return None + except Exception: + self.logger.exception("Could not locate plugin {key}".format(key=key)) + return None + + # Create a simple dummy entry first ... + plugin = PluginInfo( + key, + location, + None, + name=name, + version=version, + description=summary, + author=author, + url=url, + license=license, + ) + plugin.bundled = bundled + + if self._is_plugin_disabled(key): + self.logger.info("Plugin {} is disabled.".format(plugin)) + plugin.forced_disabled = True + + if self._is_plugin_blacklisted(key) or ( + plugin.version is not None + and self._is_plugin_version_blacklisted(key, plugin.version) + ): + self.logger.warning("Plugin {} is blacklisted.".format(plugin)) + plugin.blacklisted = True + + python_version = get_python_version_string() + if self._is_plugin_incompatible(key, plugin): + if plugin.invalid_syntax: + self.logger.warning( + "Plugin {} can't be compiled under Python {} due to invalid syntax".format( + plugin, python_version + ) + ) + else: + self.logger.warning( + "Plugin {} is not compatible to Python {} (compatibility string: {}).".format( + plugin, python_version, plugin.pythoncompat + ) + ) + plugin.incompatible = True + + if not plugin.validate( + "before_import", additional_validators=self.plugin_validators + ): + return plugin + + # ... then create and return the real one + return self._import_plugin( + key, + spec, + module_name=module_name, + name=name, + location=plugin.location, + version=version, + summary=summary, + author=author, + url=url, + license=license, + bundled=bundled, + parsed_metadata=plugin.parsed_metadata, + ) + + def _import_plugin( + self, + key, + spec, + module_name=None, + name=None, + location=None, + version=None, + summary=None, + author=None, + url=None, + license=None, + bundled=False, + parsed_metadata=None, + ): + try: + if module_name: + module = _load_module(module_name, spec) + else: + module = _load_module(key, spec) + + plugin = PluginInfo( + key, + location, + module, + name=name, + version=version, + description=summary, + author=author, + url=url, + license=license, + parsed_metadata=parsed_metadata, + ) + + plugin.bundled = bundled + except Exception: + self.logger.exception("Error loading plugin {key}".format(key=key)) + return None + + if plugin.check(): + return plugin + else: + self.logger.info( + "Plugin {plugin} did not pass check, not loading.".format( + plugin=str(plugin) + ) + ) + return None + + def _is_plugin_disabled(self, key): + return key in self.plugin_disabled_list or key.endswith("disabled") + + def _is_plugin_blacklisted(self, key): + return key in self.plugin_blacklist + + def _is_plugin_incompatible(self, key, plugin): + return ( + not plugin.bundled + and not is_python_compatible(plugin.pythoncompat) + and key not in self.compatibility_ignored_list + ) + + def _is_plugin_version_blacklisted(self, key, version): + def matches_plugin(entry): + if isinstance(entry, (tuple, list)) and len(entry) == 2: + entry_key, entry_version = entry + return entry_key == key and entry_version.specifier.contains( + str(version), prereleases=True + ) + return False + + return any(map(matches_plugin, self.plugin_blacklist)) + + def reload_plugins( + self, startup=False, initialize_implementations=True, force_reload=None + ): + """ + Reloads plugins, detecting newly added ones in the process. + + Args: + startup (boolean): whether this is called during startup of the platform + initialize_implementations (boolean): whether plugin implementations should be initialized + force_reload (list): list of plugin identifiers which should be force reloaded + """ + self.logger.info( + "Loading plugins from {folders} and installed plugin packages...".format( + folders=", ".join( + map( + lambda x: x[0] if isinstance(x, tuple) else str(x), + self.plugin_folders, + ) + ) + ) + ) + + if force_reload is None: + force_reload = [] + + added, found = self.find_plugins( + existing={k: v for k, v in self.plugins.items() if k not in force_reload}, + incl_all_found=True, + ) + + # let's clean everything we DIDN'T find first + removed = [ + key + for key in list(self.enabled_plugins.keys()) + + list(self.disabled_plugins.keys()) + if key not in found + ] + for key in removed: + try: + del self.enabled_plugins[key] + except KeyError: + pass + + try: + del self.disabled_plugins[key] + except KeyError: + pass + + self.disabled_plugins.update(added) + + # 1st pass: loading the plugins + for name, plugin in added.items(): + try: + if ( + plugin.looks_like_plugin + and not plugin.blacklisted + and not plugin.forced_disabled + and not plugin.incompatible + ): + self.load_plugin( + name, + plugin, + startup=startup, + initialize_implementation=initialize_implementations, + ) + except PluginNeedsRestart: + pass + except PluginLifecycleException as e: + self.logger.info(str(e)) + + self.on_plugins_loaded( + startup=startup, + initialize_implementations=initialize_implementations, + force_reload=force_reload, + ) + + # 2nd pass: enabling those plugins that need enabling + for name, plugin in added.items(): + try: + if ( + plugin.loaded + and plugin.looks_like_plugin + and not plugin.forced_disabled + and not plugin.incompatible + and not self._is_plugin_disabled(name) + ): + if plugin.blacklisted: + self.logger.warning( + "Plugin {} is blacklisted. Not enabling it.".format(plugin) + ) + continue + self.enable_plugin( + name, + plugin=plugin, + initialize_implementation=initialize_implementations, + startup=startup, + ) + except PluginNeedsRestart: + pass + except PluginLifecycleException as e: + self.logger.info(str(e)) + + self.on_plugins_enabled( + startup=startup, + initialize_implementations=initialize_implementations, + force_reload=force_reload, + ) + + if len(self.enabled_plugins) <= 0: + self.logger.info("No plugins found") + else: + self.logger.info( + "Found {count} plugin(s) providing {implementations} mixin implementations, {hooks} hook handlers".format( + count=len(self.enabled_plugins) + len(self.disabled_plugins), + implementations=len(self.plugin_implementations), + hooks=sum(map(lambda x: len(x), self.plugin_hooks.values())), + ) + ) + + def mark_plugin(self, name, **flags): + """ + Mark plugin ``name`` with an arbitrary number of flags. + + Args: + name (str): plugin identifier + **flags (dict): dictionary of flag names and values + """ + if name not in self.plugins: + self.logger.debug( + "Trying to mark an unknown plugin {name}".format(**locals()) + ) + + for key, value in flags.items(): + if value is None: + continue + + if value and name not in self.marked_plugins[key]: + self.marked_plugins[key].append(name) + elif not value and name in self.marked_plugins[key]: + self.marked_plugins[key].remove(name) + + def is_plugin_marked(self, name, flag): + """ + Checks whether a plugin has been marked with a certain flag. + + Args: + name (str): the plugin's identifier + flag (str): the flag to check + + Returns: + (boolean): True if the plugin has been flagged, False otherwise + """ + if name not in self.plugins: + return False + + return name in self.marked_plugins[flag] + + def load_plugin( + self, name, plugin=None, startup=False, initialize_implementation=True + ): + if name not in self.plugins: + self.logger.warning( + "Trying to load an unknown plugin {name}".format(**locals()) + ) + return + + if plugin is None: + plugin = self.plugins[name] + + try: + if not plugin.validate( + "before_load", additional_validators=self.plugin_validators + ): + return + + plugin.load() + plugin.validate("after_load", additional_validators=self.plugin_validators) + self.on_plugin_loaded(name, plugin) + plugin.loaded = True + + self.logger.debug("Loaded plugin {name}: {plugin}".format(**locals())) + except PluginLifecycleException as e: + raise e + except Exception: + self.logger.exception("There was an error loading plugin %s" % name) + + def unload_plugin(self, name): + if name not in self.plugins: + self.logger.warning( + "Trying to unload unknown plugin {name}".format(**locals()) + ) + return + + plugin = self.plugins[name] + + try: + if plugin.enabled: + self.disable_plugin(name, plugin=plugin) + + plugin.unload() + self.on_plugin_unloaded(name, plugin) + + if name in self.enabled_plugins: + del self.enabled_plugins[name] + + if name in self.disabled_plugins: + del self.disabled_plugins[name] + + plugin.loaded = False + + self.logger.debug("Unloaded plugin {name}: {plugin}".format(**locals())) + except PluginLifecycleException as e: + raise e + except Exception: + self.logger.exception( + "There was an error unloading plugin {name}".format(**locals()) + ) + + # make sure the plugin is NOT in the list of enabled plugins but in the list of disabled plugins + if name in self.enabled_plugins: + del self.enabled_plugins[name] + if name not in self.disabled_plugins: + self.disabled_plugins[name] = plugin + + def enable_plugin( + self, name, plugin=None, initialize_implementation=True, startup=False + ): + """Enables a plugin""" + if name not in self.disabled_plugins: + self.logger.warning( + "Tried to enable plugin {name}, however it is not disabled".format( + **locals() + ) + ) + return + + if plugin is None: + plugin = self.disabled_plugins[name] + + if not startup and self.is_restart_needing_plugin(plugin): + raise PluginNeedsRestart(name) + + if self.has_obsolete_hooks(plugin): + raise PluginCantEnable( + name, + "Dependency on obsolete hooks detected, full functionality cannot be guaranteed", + ) + + try: + if not plugin.validate( + "before_enable", additional_validators=self.plugin_validators + ): + return False + + plugin.enable() + self._activate_plugin(name, plugin) + except PluginLifecycleException as e: + raise e + except Exception: + self.logger.exception( + "There was an error while enabling plugin {name}".format(**locals()) + ) + return False + else: + if name in self.disabled_plugins: + del self.disabled_plugins[name] + self.enabled_plugins[name] = plugin + plugin.enabled = True + + if plugin.implementation: + if initialize_implementation: + if not self.initialize_implementation_of_plugin(name, plugin): + return False + plugin.implementation.on_plugin_enabled() + self.on_plugin_enabled(name, plugin) + + self.logger.debug("Enabled plugin {name}: {plugin}".format(**locals())) + + return True + + def disable_plugin(self, name, plugin=None): + """Disables a plugin""" + if name not in self.enabled_plugins: + self.logger.warning( + "Tried to disable plugin {name}, however it is not enabled".format( + **locals() + ) + ) + return + + if plugin is None: + plugin = self.enabled_plugins[name] + + if self.is_restart_needing_plugin(plugin): + raise PluginNeedsRestart(name) + + try: + plugin.disable() + self._deactivate_plugin(name, plugin) + except PluginLifecycleException as e: + raise e + except Exception: + self.logger.exception( + "There was an error while disabling plugin {name}".format(**locals()) + ) + return False + else: + if name in self.enabled_plugins: + del self.enabled_plugins[name] + self.disabled_plugins[name] = plugin + plugin.enabled = False + + if plugin.implementation: + plugin.implementation.on_plugin_disabled() + self.on_plugin_disabled(name, plugin) + + self.logger.debug("Disabled plugin {name}: {plugin}".format(**locals())) + + return True + + def _activate_plugin(self, name, plugin): + plugin.hotchangeable = self.is_restart_needing_plugin(plugin) + + # evaluate registered hooks + for hook, definition in plugin.hooks.items(): + try: + callback, order = self._get_callback_and_order(definition) + except ValueError as e: + self.logger.warning( + "There is something wrong with the hook definition {} for plugin {}: {}".format( + definition, name, str(e) + ) + ) + continue + + self._plugin_hooks[hook].append((order, name, callback)) + self._sort_hooks(hook) + + # evaluate registered implementation + if plugin.implementation: + mixins = self.mixins_matching_bases( + plugin.implementation.__class__, *self.plugin_bases + ) + for mixin in mixins: + self.plugin_implementations_by_type[mixin].append( + (name, plugin.implementation) + ) + if not getattr(plugin.implementation, "__timing_wrapped", False): + for method in filter( + lambda a: not a.startswith("_") and callable(getattr(mixin, a)), + dir(mixin), + ): + # wrap method with time_this + setattr( + plugin.implementation, + method, + time_this( + logtarget=self.plugin_timings_logtarget, + expand_logtarget=True, + message=self.plugin_timings_message, + )(getattr(plugin.implementation, method)), + ) + + self.plugin_implementations[name] = plugin.implementation + plugin.implementation.__timing_wrapped = True + + def _deactivate_plugin(self, name, plugin): + for hook, definition in plugin.hooks.items(): + try: + callback, order = self._get_callback_and_order(definition) + except ValueError as e: + self.logger.warning( + "There is something wrong with the hook definition {} for plugin {}: {}".format( + definition, name, str(e) + ) + ) + continue + + try: + self._plugin_hooks[hook].remove((order, name, callback)) + self._sort_hooks(hook) + except ValueError: + # that's ok, the plugin was just not registered for the hook + pass + + if plugin.implementation is not None: + if name in self.plugin_implementations: + del self.plugin_implementations[name] + + mixins = self.mixins_matching_bases( + plugin.implementation.__class__, *self.plugin_bases + ) + for mixin in mixins: + try: + self.plugin_implementations_by_type[mixin].remove( + (name, plugin.implementation) + ) + except ValueError: + # that's ok, the plugin was just not registered for the type + pass + + def is_restart_needing_plugin(self, plugin): + """Checks whether the plugin needs a restart on changes""" + return ( + plugin.needs_restart + or self.has_restart_needing_implementation(plugin) + or self.has_restart_needing_hooks(plugin) + ) + + def has_restart_needing_implementation(self, plugin): + """Checks whether the plugin's implementation needs a restart on changes""" + return self.has_any_of_mixins(plugin, RestartNeedingPlugin) + + def has_restart_needing_hooks(self, plugin): + """Checks whether the plugin has any hooks that need a restart on changes""" + return self.has_any_of_hooks(plugin, self.plugin_restart_needing_hooks) + + def has_obsolete_hooks(self, plugin): + """Checks whether the plugin uses any obsolete hooks""" + return self.has_any_of_hooks(plugin, self.plugin_obsolete_hooks) + + def is_restart_needing_hook(self, hook): + """Checks whether a hook needs a restart on changes""" + return self.hook_matches_hooks(hook, self.plugin_restart_needing_hooks) + + def is_obsolete_hook(self, hook): + """Checks whether a hook is obsolete""" + return self.hook_matches_hooks(hook, self.plugin_obsolete_hooks) + + @staticmethod + def has_any_of_hooks(plugin, *hooks): + """ + Tests if the ``plugin`` contains any of the provided ``hooks``. + + Uses :func:`octoprint.plugin.core.PluginManager.hook_matches_hooks`. + + Args: + plugin: plugin to test hooks for + *hooks: hooks to test against + + Returns: + (bool): True if any of the plugin's hooks match the provided hooks, + False otherwise. + """ + + if hooks and len(hooks) == 1 and isinstance(hooks[0], (list, tuple)): + hooks = hooks[0] + + hooks = list(filter(lambda hook: hook is not None, hooks)) + if not hooks: + return False + if not plugin or not plugin.hooks: + return False + + plugin_hooks = plugin.hooks.keys() + + return any( + map(lambda hook: PluginManager.hook_matches_hooks(hook, *hooks), plugin_hooks) + ) + + @staticmethod + def hook_matches_hooks(hook, *hooks): + """ + Tests if ``hook`` matches any of the provided ``hooks`` to test for. + + ``hook`` is expected to be an exact hook name. + + ``hooks`` is expected to be a list containing one or more hook names or + patterns. That can be either an exact hook name or an + :func:`fnmatch.fnmatch` pattern. + + Args: + hook: the hook to test + hooks: the hook name patterns to test against + + Returns: + (bool): True if the ``hook`` matches any of the ``hooks``, False otherwise. + + """ + + if hooks and len(hooks) == 1 and isinstance(hooks[0], (list, tuple)): + hooks = hooks[0] + + hooks = list(filter(lambda hook: hook is not None, hooks)) + if not hooks: + return False + if not hook: + return False + + return any(map(lambda h: fnmatch.fnmatch(hook, h), hooks)) + + @staticmethod + def mixins_matching_bases(klass, *bases): + result = set() + for c in inspect.getmro(klass): + if c == klass or c in bases: + # ignore the exact class and our bases + continue + if issubclass(c, bases): + result.add(c) + return result + + @staticmethod + def has_any_of_mixins(plugin, *mixins): + """ + Tests if the ``plugin`` has an implementation implementing any + of the provided ``mixins``. + + Args: + plugin: plugin for which to check the implementation + *mixins: mixins to test against + + Returns: + (bool): True if the plugin's implementation implements any of the + provided mixins, False otherwise. + """ + + if mixins and len(mixins) == 1 and isinstance(mixins[0], (list, tuple)): + mixins = mixins[0] + + mixins = list(filter(lambda mixin: mixin is not None, mixins)) + if not mixins: + return False + if not plugin or not plugin.implementation: + return False + + return isinstance(plugin.implementation, tuple(mixins)) + + def initialize_implementations( + self, + additional_injects=None, + additional_inject_factories=None, + additional_pre_inits=None, + additional_post_inits=None, + ): + for name, plugin in self.enabled_plugins.items(): + self.initialize_implementation_of_plugin( + name, + plugin, + additional_injects=additional_injects, + additional_inject_factories=additional_inject_factories, + additional_pre_inits=additional_pre_inits, + additional_post_inits=additional_post_inits, + ) + + self.logger.info( + "Initialized {count} plugin implementation(s)".format( + count=len(self.plugin_implementations) + ) + ) + + def initialize_implementation_of_plugin( + self, + name, + plugin, + additional_injects=None, + additional_inject_factories=None, + additional_pre_inits=None, + additional_post_inits=None, + ): + if plugin.implementation is None: + return + + return self.initialize_implementation( + name, + plugin, + plugin.implementation, + additional_injects=additional_injects, + additional_inject_factories=additional_inject_factories, + additional_pre_inits=additional_pre_inits, + additional_post_inits=additional_post_inits, + ) + + def initialize_implementation( + self, + name, + plugin, + implementation, + additional_injects=None, + additional_inject_factories=None, + additional_pre_inits=None, + additional_post_inits=None, + ): + if additional_injects is None: + additional_injects = {} + if additional_inject_factories is None: + additional_inject_factories = [] + if additional_pre_inits is None: + additional_pre_inits = [] + if additional_post_inits is None: + additional_post_inits = [] + + injects = self.implementation_injects + injects.update(additional_injects) + + inject_factories = self.implementation_inject_factories + inject_factories += additional_inject_factories + + pre_inits = self.implementation_pre_inits + pre_inits += additional_pre_inits + + post_inits = self.implementation_post_inits + post_inits += additional_post_inits + + try: + kwargs = dict(injects) + + kwargs.update( + { + "identifier": name, + "plugin_name": plugin.name, + "plugin_version": plugin.version, + "plugin_info": plugin, + "basefolder": os.path.realpath(plugin.location), + "logger": logging.getLogger(self.logging_prefix + name), + } + ) + + # inject the additional_injects + for arg, value in kwargs.items(): + setattr(implementation, "_" + arg, value) + + # inject any injects produced in the additional_inject_factories + for factory in inject_factories: + try: + return_value = factory(name, implementation) + except Exception: + self.logger.exception( + "Exception while executing injection factory %r" % factory + ) + else: + if return_value is not None: + if isinstance(return_value, dict): + for arg, value in return_value.items(): + setattr(implementation, "_" + arg, value) + + # execute any additional pre init methods + for pre_init in pre_inits: + pre_init(name, implementation) + + implementation.initialize() + + # execute any additional post init methods + for post_init in post_inits: + post_init(name, implementation) + + except Exception as e: + self._deactivate_plugin(name, plugin) + plugin.enabled = False + + if isinstance(e, PluginLifecycleException): + raise e + else: + self.logger.exception( + "Exception while initializing plugin {name}, disabling it".format( + **locals() + ) + ) + return False + else: + self.on_plugin_implementations_initialized(name, plugin) + + self.logger.debug( + "Initialized plugin mixin implementation for plugin {name}".format(**locals()) + ) + return True + + def log_all_plugins( + self, + show_bundled=True, + bundled_str=(" (bundled)", ""), + show_location=True, + location_str=" = {location}", + show_enabled=True, + enabled_str=(" ", "!", "#", "*"), + only_to_handler=None, + ): + all_plugins = list(self.enabled_plugins.values()) + list( + self.disabled_plugins.values() + ) + + def _log(message, level=logging.INFO): + if only_to_handler is not None: + import octoprint.logging + + octoprint.logging.log_to_handler( + self.logger, only_to_handler, level, message, [] + ) + else: + self.logger.log(level, message) + + if len(all_plugins) <= 0: + _log("No plugins available") + else: + formatted_plugins = "\n".join( + map( + lambda x: "| " + + x.long_str( + show_bundled=show_bundled, + bundled_strs=bundled_str, + show_location=show_location, + location_str=location_str, + show_enabled=show_enabled, + enabled_strs=enabled_str, + ), + sorted(self.plugins.values(), key=lambda x: str(x).lower()), + ) + ) + legend = "Prefix legend: {1} = disabled, {2} = blacklisted, {3} = incompatible".format( + *enabled_str + ) + _log( + "{count} plugin(s) registered with the system:\n{plugins}\n{legend}".format( + count=len(all_plugins), plugins=formatted_plugins, legend=legend + ) + ) + + def get_plugin(self, identifier, require_enabled=True): + """ + Retrieves the module of the plugin identified by ``identifier``. If the plugin is not registered or disabled and + ``required_enabled`` is True (the default) None will be returned. + + Arguments: + identifier (str): The identifier of the plugin to retrieve. + require_enabled (boolean): Whether to only return the plugin if is enabled (True, default) or also if it's + disabled. + + Returns: + module: The requested plugin module or None + """ + + plugin_info = self.get_plugin_info(identifier, require_enabled=require_enabled) + if plugin_info is not None: + return plugin_info.instance + return None + + def get_plugin_info(self, identifier, require_enabled=True): + """ + Retrieves the :class:`PluginInfo` instance identified by ``identifier``. If the plugin is not registered or + disabled and ``required_enabled`` is True (the default) None will be returned. + + Arguments: + identifier (str): The identifier of the plugin to retrieve. + require_enabled (boolean): Whether to only return the plugin if is enabled (True, default) or also if it's + disabled. + + Returns: + ~.PluginInfo: The requested :class:`PluginInfo` or None + """ + + if identifier in self.enabled_plugins: + return self.enabled_plugins[identifier] + elif not require_enabled and identifier in self.disabled_plugins: + return self.disabled_plugins[identifier] + + return None + + def get_hooks(self, hook): + """ + Retrieves all registered handlers for the specified hook. + + Arguments: + hook (str): The hook for which to retrieve the handlers. + + Returns: + dict: A dict containing all registered handlers mapped by their plugin's identifier. + """ + + if hook not in self.plugin_hooks: + return {} + + result = OrderedDict() + for h in self.plugin_hooks[hook]: + result[h[0]] = time_this( + logtarget=self.plugin_timings_logtarget, + expand_logtarget=True, + message=self.plugin_timings_message, + )(h[1]) + return result + + def get_implementations(self, *types, **kwargs): + """ + Get all mixin implementations that implement *all* of the provided ``types``. + + Arguments: + types (one or more type): The types a mixin implementation needs to implement in order to be returned. + + Returns: + list: A list of all found implementations + """ + + sorting_context = kwargs.get("sorting_context", None) + + result = None + + for t in types: + implementations = self.plugin_implementations_by_type[t] + if result is None: + result = set(implementations) + else: + result = result.intersection(implementations) + + if result is None: + return [] + + def sort_func(impl): + sorting_value = None + if sorting_context is not None and isinstance(impl[1], SortablePlugin): + try: + sorting_value = impl[1].get_sorting_key(sorting_context) + except Exception: + self.logger.exception( + "Error while trying to retrieve sorting order for plugin {}".format( + impl[0] + ) + ) + + if sorting_value is not None: + try: + sorting_value = int(sorting_value) + except ValueError: + self.logger.warning( + "The order value returned by {} for sorting context {} is not a valid integer, ignoring it".format( + impl[0], sorting_context + ) + ) + sorting_value = None + + plugin_info = self.get_plugin_info(impl[0], require_enabled=False) + return ( + sorting_value is None, + sv(sorting_value), + not plugin_info.bundled if plugin_info else True, + sv(impl[0]), + ) + + return [impl[1] for impl in sorted(result, key=sort_func)] + + def get_filtered_implementations(self, f, *types, **kwargs): + """ + Get all mixin implementations that implement *all* of the provided ``types`` and match the provided filter `f`. + + Arguments: + f (callable): A filter function returning True for implementations to return and False for those to exclude. + types (one or more type): The types a mixin implementation needs to implement in order to be returned. + + Returns: + list: A list of all found and matching implementations. + """ + + assert callable(f) + implementations = self.get_implementations( + *types, sorting_context=kwargs.get("sorting_context", None) + ) + return list(filter(f, implementations)) + + def get_helpers(self, name, *helpers): + """ + Retrieves the named ``helpers`` for the plugin with identifier ``name``. + + If the plugin is not available, returns None. Otherwise returns a :class:`dict` with the requested plugin + helper names mapped to the method - if a helper could not be resolved, it will be missing from the dict. + + Arguments: + name (str): Identifier of the plugin for which to look up the ``helpers``. + helpers (one or more str): Identifiers of the helpers of plugin ``name`` to return. + + Returns: + dict: A dictionary of all resolved helpers, mapped by their identifiers, or None if the plugin was not + registered with the system. + """ + + if name not in self.enabled_plugins: + return None + plugin = self.enabled_plugins[name] + + all_helpers = plugin.helpers + if len(helpers): + return {k: v for (k, v) in all_helpers.items() if k in helpers} + else: + return all_helpers + + def register_message_receiver(self, client): + """ + Registers a ``client`` for receiving plugin messages. The ``client`` needs to be a callable accepting two + input arguments, ``plugin`` (the sending plugin's identifier) and ``data`` (the message itself), and one + optional keyword argument, ``permissions`` (an optional list of permissions to test against). + """ + + if client is None: + return + self.registered_clients.append(client) + + def unregister_message_receiver(self, client): + """ + Unregisters a ``client`` for receiving plugin messages. + """ + + try: + self.registered_clients.remove(client) + except ValueError: + # not registered + pass + + def send_plugin_message(self, plugin, data, permissions=None): + """ + Sends ``data`` in the name of ``plugin`` to all currently registered message receivers by invoking them + with the three arguments. + + Arguments: + plugin (str): The sending plugin's identifier. + data (object): The message. + permissions (list): A list of permissions to test against in the client. + """ + + for client in self.registered_clients: + try: + client(plugin, data, permissions=permissions) + except Exception: + self.logger.exception("Exception while sending plugin data to client") + + def _sort_hooks(self, hook): + self._plugin_hooks[hook] = sorted( + self._plugin_hooks[hook], + key=lambda x: (x[0] is None, sv(x[0]), sv(x[1]), sv(x[2])), + ) + + def _get_callback_and_order(self, hook): + if callable(hook): + return hook, None + + elif isinstance(hook, tuple) and len(hook) == 2: + callback, order = hook + + # test that callback is a callable + if not callable(callback): + raise ValueError("Hook callback is not a callable") + + # test that number is an int + try: + int(order) + except ValueError: + raise ValueError("Hook order is not a number") + + return callback, order + + else: + raise ValueError( + "Invalid hook definition, neither a callable nor a 2-tuple (callback, order): {!r}".format( + hook + ) + ) def is_sub_path_of(path, parent): - """ - Tests if `path` is a sub path (or identical) to `path`. - - >>> is_sub_path_of("/a/b/c", "/a/b") - True - >>> is_sub_path_of("/a/b/c", "/a/b2") - False - >>> is_sub_path_of("/a/b/c", "/b/c") - False - >>> is_sub_path_of("/foo/bar/../../a/b/c", "/a/b") - True - >>> is_sub_path_of("/a/b", "/a/b") - True - """ - rel_path = os.path.relpath(os.path.realpath(path), - os.path.realpath(parent)) - return not (rel_path == os.pardir or - rel_path.startswith(os.pardir + os.sep)) + """ + Tests if `path` is a sub path (or identical) to `path`. + + >>> is_sub_path_of("/a/b/c", "/a/b") + True + >>> is_sub_path_of("/a/b/c", "/a/b2") + False + >>> is_sub_path_of("/a/b/c", "/b/c") + False + >>> is_sub_path_of("/foo/bar/../../a/b/c", "/a/b") + True + >>> is_sub_path_of("/a/b", "/a/b") + True + """ + rel_path = os.path.relpath(os.path.realpath(path), os.path.realpath(parent)) + return not (rel_path == os.pardir or rel_path.startswith(os.pardir + os.sep)) def is_editable_install(install_dir, package, module, location): - package_link = os.path.join(install_dir, "{}.egg-link".format(package)) - if os.path.isfile(package_link): - expected_target = os.path.normcase(os.path.realpath(location)) - try: - with open(package_link) as f: - contents = f.readlines() - for line in contents: - target = os.path.normcase(os.path.realpath(os.path.join(line.strip(), module))) - if target == expected_target: - return True - except: - pass - return False - - -class InstalledEntryPoint(pkginfo.Installed): - - def __init__(self, entry_point, metadata_version=None): - self.entry_point = entry_point - package = entry_point.module_name - pkginfo.Installed.__init__(self, package, metadata_version=metadata_version) - - def read(self): - import sys - import glob - import warnings - - opj = os.path.join - if self.package is not None: - package = self.package.__package__ - if package is None: - package = self.package.__name__ - - project = pkg_resources.to_filename(pkg_resources.safe_name(self.entry_point.dist.project_name)) - - package_pattern = '%s*.egg-info' % package - project_pattern = '%s*.egg-info' % project - - file = getattr(self.package, '__file__', None) - if file is not None: - candidates = [] - - def _add_candidate(where): - candidates.extend(glob.glob(where)) - - for entry in sys.path: - if file.startswith(entry): - _add_candidate(opj(entry, 'EGG-INFO')) # egg? - for pattern in (package_pattern, project_pattern): # dist-installed? - _add_candidate(opj(entry, pattern)) - - dir, name = os.path.split(self.package.__file__) - for pattern in (package_pattern, project_pattern): - _add_candidate(opj(dir, pattern)) - _add_candidate(opj(dir, '..', pattern)) - - for candidate in candidates: - if os.path.isdir(candidate): - path = opj(candidate, 'PKG-INFO') - else: - path = candidate - if os.path.exists(path): - with open(path) as f: - return f.read() - warnings.warn('No PKG-INFO found for package: %s' % self.package_name) + package_link = os.path.join(install_dir, "{}.egg-link".format(package)) + if os.path.isfile(package_link): + expected_target = os.path.normcase(os.path.realpath(location)) + try: + with io.open(package_link, "rt", encoding="utf-8") as f: + contents = f.readlines() + for line in contents: + target = os.path.normcase( + os.path.realpath(os.path.join(line.strip(), module)) + ) + if target == expected_target: + return True + except Exception: + raise # TODO really ignore this? + pass + return False + + +class EntryPointMetadata(pkginfo.Distribution): + def __init__(self, entry_point): + self.entry_point = entry_point + self.extractMetadata() + + def read(self): + import warnings + + metadata_files = ("METADATA", "PKG-INFO") # wheel # egg + + if self.entry_point and self.entry_point.dist: + for metadata_file in metadata_files: + try: + return self.entry_point.dist.read_text(metadata_file) + except (IOError, OSError): # noqa: B014 + # file not found, metadata file might be missing, ignore + # IOError: file not found in Py2 + # OSError (specifically FileNotFoundError): file not found in Py3 + pass + + warnings.warn( + "No package metadata found for package {}".format( + self.entry_point.module_name + ) + ) class Plugin(object): - """ - The parent class of all plugin implementations. + """ + The parent class of all plugin implementations. + + .. attribute:: _identifier - .. attribute:: _identifier + The identifier of the plugin. Injected by the plugin core system upon initialization of the implementation. - The identifier of the plugin. Injected by the plugin core system upon initialization of the implementation. + .. attribute:: _plugin_name - .. attribute:: _plugin_name + The name of the plugin. Injected by the plugin core system upon initialization of the implementation. - The name of the plugin. Injected by the plugin core system upon initialization of the implementation. + .. attribute:: _plugin_version - .. attribute:: _plugin_version + The version of the plugin. Injected by the plugin core system upon initialization of the implementation. - The version of the plugin. Injected by the plugin core system upon initialization of the implementation. + .. attribute:: _basefolder - .. attribute:: _basefolder + The base folder of the plugin. Injected by the plugin core system upon initialization of the implementation. - The base folder of the plugin. Injected by the plugin core system upon initialization of the implementation. + .. attribute:: _logger - .. attribute:: _logger + The logger instance to use, with the logging name set to the :attr:`PluginManager.logging_prefix` of the + :class:`PluginManager` concatenated with :attr:`_identifier`. Injected by the plugin core system upon + initialization of the implementation. + """ - The logger instance to use, with the logging name set to the :attr:`PluginManager.logging_prefix` of the - :class:`PluginManager` concatenated with :attr:`_identifier`. Injected by the plugin core system upon - initialization of the implementation. - """ + def __init__(self): + self._identifier = None + self._plugin_name = None + self._plugin_version = None + self._basefolder = None + self._logger = None - def __init__(self): - self._identifier = None - self._plugin_name = None - self._plugin_version = None - self._basefolder = None - self._logger = None + def initialize(self): + """ + Called by the plugin core after performing all injections. Override this to initialize your implementation. + """ + pass - def initialize(self): - """ - Called by the plugin core after performing all injections. Override this to initialize your implementation. - """ - pass + def on_plugin_enabled(self): + pass - def on_plugin_enabled(self): - pass + def on_plugin_disabled(self): + pass - def on_plugin_disabled(self): - pass class RestartNeedingPlugin(Plugin): - """ - Mixin for plugin types that need a restart after enabling/disabling them. - """ + """ + Mixin for plugin types that need a restart after enabling/disabling them. + """ + class SortablePlugin(Plugin): - """ - Mixin for plugin types that are sortable. - """ + """ + Mixin for plugin types that are sortable. + """ + + def get_sorting_key(self, context=None): + """ + Returns the sorting key to use for the implementation in the specified ``context``. - def get_sorting_key(self, context=None): - """ - Returns the sorting key to use for the implementation in the specified ``context``. + May return ``None`` if order is irrelevant. - May return ``None`` if order is irrelevant. + Implementations returning None will be ordered by plugin identifier + after all implementations which did return a sorting key value that was + not None sorted by that. - Implementations returning None will be ordered by plugin identifier - after all implementations which did return a sorting key value that was - not None sorted by that. + Arguments: + context (str): The sorting context for which to provide the + sorting key value. - Arguments: - context (str): The sorting context for which to provide the - sorting key value. + Returns: + int or None: An integer signifying the sorting key value of the plugin + (sorting will be done ascending), or None if the implementation + doesn't care about calling order. + """ + return None - Returns: - int or None: An integer signifying the sorting key value of the plugin - (sorting will be done ascending), or None if the implementation - doesn't care about calling order. - """ - return None class PluginNeedsRestart(Exception): - def __init__(self, name): - Exception.__init__(self) - self.name = name - self.message = "Plugin {name} cannot be enabled or disabled after system startup".format(**locals()) + def __init__(self, name): + Exception.__init__(self) + self.name = name + self.message = ( + "Plugin {name} cannot be enabled or disabled after system startup".format( + **locals() + ) + ) + class PluginLifecycleException(Exception): - def __init__(self, name, reason, message): - Exception.__init__(self) - self.name = name - self.reason = reason + def __init__(self, name, reason, message): + Exception.__init__(self) + self.name = name + self.reason = reason + + self.message = message.format(**locals()) - self.message = message.format(**locals()) + def __str__(self): + return self.message - def __str__(self): - return self.message class PluginCantInitialize(PluginLifecycleException): - def __init__(self, name, reason): - PluginLifecycleException.__init__(self, name, reason, "Plugin {name} cannot be initialized: {reason}") + def __init__(self, name, reason): + PluginLifecycleException.__init__( + self, name, reason, "Plugin {name} cannot be initialized: {reason}" + ) + class PluginCantEnable(PluginLifecycleException): - def __init__(self, name, reason): - PluginLifecycleException.__init__(self, name, reason, "Plugin {name} cannot be enabled: {reason}") + def __init__(self, name, reason): + PluginLifecycleException.__init__( + self, name, reason, "Plugin {name} cannot be enabled: {reason}" + ) + class PluginCantDisable(PluginLifecycleException): - def __init__(self, name, reason): - PluginLifecycleException.__init__(self, name, reason, "Plugin {name} cannot be disabled: {reason}") + def __init__(self, name, reason): + PluginLifecycleException.__init__( + self, name, reason, "Plugin {name} cannot be disabled: {reason}" + ) diff --git a/src/octoprint/plugin/types.py b/src/octoprint/plugin/types.py index b39dcac91a..f0b7b036e5 100644 --- a/src/octoprint/plugin/types.py +++ b/src/octoprint/plugin/types.py @@ -1,4 +1,3 @@ -# coding=utf-8 """ This module bundles all of OctoPrint's supported plugin implementation types as well as their common parent class, :class:`OctoPrintPlugin`. @@ -15,2007 +14,2231 @@ :members: """ - -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals __author__ = "Gina Häußge " -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" -from .core import (Plugin, RestartNeedingPlugin, SortablePlugin) +from .core import Plugin, RestartNeedingPlugin, SortablePlugin -# noinspection PyCompatibility -from past.builtins import basestring class OctoPrintPlugin(Plugin): - """ - The parent class of all OctoPrint plugin mixins. + """ + The parent class of all OctoPrint plugin mixins. + + .. attribute:: _plugin_manager - .. attribute:: _plugin_manager + The :class:`~octoprint.plugin.core.PluginManager` instance. Injected by the plugin core system upon + initialization of the implementation. - The :class:`~octoprint.plugin.core.PluginManager` instance. Injected by the plugin core system upon - initialization of the implementation. + .. attribute:: _printer_profile_manager - .. attribute:: _printer_profile_manager + The :class:`~octoprint.printer.profile.PrinterProfileManager` instance. Injected by the plugin core system upon + initialization of the implementation. - The :class:`~octoprint.printer.profile.PrinterProfileManager` instance. Injected by the plugin core system upon - initialization of the implementation. + .. attribute:: _event_bus - .. attribute:: _event_bus + The :class:`~octoprint.events.EventManager` instance. Injected by the plugin core system upon initialization of + the implementation. - The :class:`~octoprint.events.EventManager` instance. Injected by the plugin core system upon initialization of - the implementation. + .. attribute:: _analysis_queue - .. attribute:: _analysis_queue + The :class:`~octoprint.filemanager.analysis.AnalysisQueue` instance. Injected by the plugin core system upon + initialization of the implementation. - The :class:`~octoprint.filemanager.analysis.AnalysisQueue` instance. Injected by the plugin core system upon - initialization of the implementation. + .. attribute:: _slicing_manager - .. attribute:: _slicing_manager + The :class:`~octoprint.slicing.SlicingManager` instance. Injected by the plugin core system upon initialization + of the implementation. - The :class:`~octoprint.slicing.SlicingManager` instance. Injected by the plugin core system upon initialization - of the implementation. + .. attribute:: _file_manager - .. attribute:: _file_manager + The :class:`~octoprint.filemanager.FileManager` instance. Injected by the plugin core system upon initialization + of the implementation. - The :class:`~octoprint.filemanager.FileManager` instance. Injected by the plugin core system upon initialization - of the implementation. + .. attribute:: _printer - .. attribute:: _printer + The :class:`~octoprint.printer.PrinterInterface` instance. Injected by the plugin core system upon initialization + of the implementation. - The :class:`~octoprint.printer.PrinterInterface` instance. Injected by the plugin core system upon initialization - of the implementation. + .. attribute:: _app_session_manager - .. attribute:: _app_session_manager + The :class:`~octoprint.access.users.SessionManager` instance. Injected by the plugin core system upon initialization of + the implementation. - The :class:`~octoprint.users.SessionManager` instance. Injected by the plugin core system upon initialization of - the implementation. + .. attribute:: _plugin_lifecycle_manager - .. attribute:: _plugin_lifecycle_manager + The :class:`~octoprint.server.LifecycleManager` instance. Injected by the plugin core system upon initialization + of the implementation. - The :class:`~octoprint.server.LifecycleManager` instance. Injected by the plugin core system upon initialization - of the implementation. + .. attribute:: _user_manager - .. attribute:: _user_manager + The :class:`~octoprint.access.users.UserManager` instance. Injected by the plugin core system upon initialization + of the implementation. - The :class:`~octoprint.users.UserManager` instance. Injected by the plugin core system upon initialization - of the implementation. + .. attribute:: _connectivity_checker - .. attribute:: _connectivity_checker + The :class:`~octoprint.util.ConnectivityChecker` instance. Injected by the plugin core system upon initialization + of the implementation. - The :class:`~octoprint.util.ConnectivityChecker` instance. Injected by the plugin core system upon initialization - of the implementation. + .. attribute:: _data_folder - .. attribute:: _data_folder + Path to the data folder for the plugin to use for any data it might have to persist. Should always be accessed + through :meth:`get_plugin_data_folder` since that function will also ensure that the data folder actually exists + and if not creating it before returning it. Injected by the plugin core system upon initialization of the + implementation. + """ - Path to the data folder for the plugin to use for any data it might have to persist. Should always be accessed - through :meth:`get_plugin_data_folder` since that function will also ensure that the data folder actually exists - and if not creating it before returning it. Injected by the plugin core system upon initialization of the - implementation. - """ + # noinspection PyMissingConstructor + def __init__(self): + self._plugin_manager = None + self._printer_profile_manager = None + self._event_bus = None + self._analysis_queue = None + self._slicing_manager = None + self._file_manager = None + self._printer = None + self._app_session_manager = None + self._plugin_lifecycle_manager = None + self._user_manager = None + self._connectivity_checker = None + self._data_folder = None - # noinspection PyMissingConstructor - def __init__(self): - self._plugin_manager = None - self._printer_profile_manager = None - self._event_bus = None - self._analysis_queue = None - self._slicing_manager = None - self._file_manager = None - self._printer = None - self._app_session_manager = None - self._plugin_lifecycle_manager = None - self._user_manager = None - self._connectivity_checker = None - self._data_folder = None + def get_plugin_data_folder(self): + """ + Retrieves the path to a data folder specifically for the plugin, ensuring it exists and if not creating it + before returning it. - def get_plugin_data_folder(self): - """ - Retrieves the path to a data folder specifically for the plugin, ensuring it exists and if not creating it - before returning it. + Plugins may use this folder for storing additional data they need for their operation. + """ + if self._data_folder is None: + raise RuntimeError( + "self._plugin_data_folder is None, has the plugin been initialized yet?" + ) - Plugins may use this folder for storing additional data they need for their operation. - """ - if self._data_folder is None: - raise RuntimeError("self._plugin_data_folder is None, has the plugin been initialized yet?") + import os - import os - if not os.path.isdir(self._data_folder): - os.makedirs(self._data_folder) - return self._data_folder + if not os.path.isdir(self._data_folder): + os.makedirs(self._data_folder) + return self._data_folder class ReloadNeedingPlugin(Plugin): - """ - Mixin for plugin types that need a reload of the UI after enabling/disabling them. - """ + """ + Mixin for plugin types that need a reload of the UI after enabling/disabling them. + """ class EnvironmentDetectionPlugin(OctoPrintPlugin, RestartNeedingPlugin): + """ + .. versionadded:: 1.3.6 + """ - def get_additional_environment(self): - pass + def get_additional_environment(self): + pass - def on_environment_detected(self, environment, *args, **kwargs): - pass + def on_environment_detected(self, environment, *args, **kwargs): + pass class StartupPlugin(OctoPrintPlugin, SortablePlugin): - """ - The ``StartupPlugin`` allows hooking into the startup of OctoPrint. It can be used to start up additional services - on or just after the startup of the server. + """ + The ``StartupPlugin`` allows hooking into the startup of OctoPrint. It can be used to start up additional services + on or just after the startup of the server. + + ``StartupPlugin`` is a :class:`~octoprint.plugin.core.SortablePlugin` and provides + sorting contexts for :meth:`~octoprint.plugin.StartupPlugin.on_startup` as well as + :meth:`~octoprint.plugin.StartupPlugin.on_after_startup`. + """ + + def on_startup(self, host, port): + """ + Called just before the server is actually launched. Plugins get supplied with the ``host`` and ``port`` the server + will listen on. Note that the ``host`` may be ``0.0.0.0`` if it will listen on all interfaces, so you can't just + blindly use this for constructing publicly reachable URLs. Also note that when this method is called, the server + is not actually up yet and none of your plugin's APIs or blueprints will be reachable yet. If you need to be + externally reachable, use :func:`on_after_startup` instead or additionally. + + .. warning:: + + Do not perform long-running or even blocking operations in your implementation or you **will** block and break the server. - ``StartupPlugin`` is a :class:`~octoprint.plugin.core.SortablePlugin` and provides - sorting contexts for :meth:`~octoprint.plugin.StartupPlugin.on_startup` as well as - :meth:`~octoprint.plugin.StartupPlugin.on_after_startup`. - """ + The relevant sorting context is ``StartupPlugin.on_startup``. - def on_startup(self, host, port): - """ - Called just before the server is actually launched. Plugins get supplied with the ``host`` and ``port`` the server - will listen on. Note that the ``host`` may be ``0.0.0.0`` if it will listen on all interfaces, so you can't just - blindly use this for constructing publicly reachable URLs. Also note that when this method is called, the server - is not actually up yet and none of your plugin's APIs or blueprints will be reachable yet. If you need to be - externally reachable, use :func:`on_after_startup` instead or additionally. + :param string host: the host the server will listen on, may be ``0.0.0.0`` + :param int port: the port the server will listen on + """ - The relevant sorting context is ``StartupPlugin.on_startup``. + pass - :param string host: the host the server will listen on, may be ``0.0.0.0`` - :param int port: the port the server will listen on - """ + def on_after_startup(self): + """ + Called just after launch of the server, so when the listen loop is actually running already. - pass + .. warning:: - def on_after_startup(self): - """ - Called just after launch of the server, so when the listen loop is actually running already. + Do not perform long-running or even blocking operations in your implementation or you **will** block and break the server. - The relevant sorting context is ``StartupPlugin.on_after_startup``. - """ + The relevant sorting context is ``StartupPlugin.on_after_startup``. + """ - pass + pass class ShutdownPlugin(OctoPrintPlugin, SortablePlugin): - """ - The ``ShutdownPlugin`` allows hooking into the shutdown of OctoPrint. It's usually used in conjunction with the - :class:`StartupPlugin` mixin, to cleanly shut down additional services again that where started by the :class:`StartupPlugin` - part of the plugin. + """ + The ``ShutdownPlugin`` allows hooking into the shutdown of OctoPrint. It's usually used in conjunction with the + :class:`StartupPlugin` mixin, to cleanly shut down additional services again that where started by the :class:`StartupPlugin` + part of the plugin. - ``ShutdownPlugin`` is a :class:`~octoprint.plugin.core.SortablePlugin` and provides a sorting context for - :meth:`~octoprint.plugin.ShutdownPlugin.on_shutdown`. - """ + ``ShutdownPlugin`` is a :class:`~octoprint.plugin.core.SortablePlugin` and provides a sorting context for + :meth:`~octoprint.plugin.ShutdownPlugin.on_shutdown`. + """ - def on_shutdown(self): - """ - Called upon the imminent shutdown of OctoPrint. + def on_shutdown(self): + """ + Called upon the imminent shutdown of OctoPrint. - The relevant sorting context is ``ShutdownPlugin.on_shutdown``. - """ - pass + .. warning:: + + Do not perform long-running or even blocking operations in your implementation or you **will** block and break the server. + + The relevant sorting context is ``ShutdownPlugin.on_shutdown``. + """ + pass class AssetPlugin(OctoPrintPlugin, RestartNeedingPlugin): - """ - The ``AssetPlugin`` mixin allows plugins to define additional static assets such as JavaScript or CSS files to - be automatically embedded into the pages delivered by the server to be used within the client sided part of - the plugin. + """ + The ``AssetPlugin`` mixin allows plugins to define additional static assets such as JavaScript or CSS files to + be automatically embedded into the pages delivered by the server to be used within the client sided part of + the plugin. + + A typical usage of the ``AssetPlugin`` functionality is to embed a custom view model to be used by templates injected + through a :class:`TemplatePlugin`. - A typical usage of the ``AssetPlugin`` functionality is to embed a custom view model to be used by templates injected - through a :class:`TemplatePlugin`. + ``AssetPlugin`` is a :class:`~octoprint.plugins.core.RestartNeedingPlugin`. + """ - ``AssetPlugin`` is a :class:`~octoprint.plugins.core.RestartNeedingPlugin`. - """ + def get_asset_folder(self): + """ + Defines the folder where the plugin stores its static assets as defined in :func:`get_assets`. Override this if + your plugin stores its assets at some other place than the ``static`` sub folder in the plugin base directory. - def get_asset_folder(self): - """ - Defines the folder where the plugin stores its static assets as defined in :func:`get_assets`. Override this if - your plugin stores its assets at some other place than the ``static`` sub folder in the plugin base directory. + :return string: the absolute path to the folder where the plugin stores its static assets + """ + import os - :return string: the absolute path to the folder where the plugin stores its static assets - """ - import os - return os.path.join(self._basefolder, "static") + return os.path.join(self._basefolder, "static") - def get_assets(self): - """ - Defines the static assets the plugin offers. The following asset types are recognized and automatically - imported at the appropriate places to be available: + def get_assets(self): + """ + Defines the static assets the plugin offers. The following asset types are recognized and automatically + imported at the appropriate places to be available: - js - JavaScript files, such as additional view models - css - CSS files with additional styles, will be embedded into delivered pages when not running in LESS mode. - less - LESS files with additional styles, will be embedded into delivered pages when running in LESS mode. + js + JavaScript files, such as additional view models + jsclient + JavaScript files containing additional parts for the JS Client Library (since 1.3.10) + css + CSS files with additional styles, will be embedded into delivered pages when not running in LESS mode. + less + LESS files with additional styles, will be embedded into delivered pages when running in LESS mode. - The expected format to be returned is a dictionary mapping one or more of these keys to a list of files of that - type, the files being represented as relative paths from the asset folder as defined via :func:`get_asset_folder`. - Example: + The expected format to be returned is a dictionary mapping one or more of these keys to a list of files of that + type, the files being represented as relative paths from the asset folder as defined via :func:`get_asset_folder`. + Example: - .. code-block:: python + .. code-block:: python - def get_assets(self): - return dict( - js=['js/my_file.js', 'js/my_other_file.js'], - css=['css/my_styles.css'], - less=['less/my_styles.less'] - ) + def get_assets(self): + return dict( + js=['js/my_file.js', 'js/my_other_file.js'], + clientjs=['clientjs/my_file.js'], + css=['css/my_styles.css'], + less=['less/my_styles.less'] + ) - The assets will be made available by OctoPrint under the URL ``/plugin//static/``, with - ``plugin identifier`` being the plugin's identifier and ``path`` being the path as defined in the asset dictionary. + The assets will be made available by OctoPrint under the URL ``/plugin//static/``, with + ``plugin identifier`` being the plugin's identifier and ``path`` being the path as defined in the asset dictionary. - Assets of the types ``js``, ``css`` and ``less`` will be automatically bundled by OctoPrint using - `Flask-Assets `_. + Assets of the types ``js``, ``css`` and ``less`` will be automatically bundled by OctoPrint using + `Flask-Assets `_. - :return dict: a dictionary describing the static assets to publish for the plugin - """ - return dict() + :return dict: a dictionary describing the static assets to publish for the plugin + """ + return {} class TemplatePlugin(OctoPrintPlugin, ReloadNeedingPlugin): - """ - Using the ``TemplatePlugin`` mixin plugins may inject their own components into the OctoPrint web interface. + """ + Using the ``TemplatePlugin`` mixin plugins may inject their own components into the OctoPrint web interface. + + Currently OctoPrint supports the following types of injections out of the box: + + Navbar + The right part of the navigation bar located at the top of the UI can be enriched with additional links. Note that + with the current implementation, plugins will always be located *to the left* of the existing links. - Currently OctoPrint supports the following types of injections out of the box: + The included template must be called ``_navbar.jinja2`` (e.g. ``myplugin_navbar.jinja2``) unless + overridden by the configuration supplied through :func:`get_template_configs`. - Navbar - The right part of the navigation bar located at the top of the UI can be enriched with additional links. Note that - with the current implementation, plugins will always be located *to the left* of the existing links. + The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The + wrapper structure will have all additional classes and styles applied as specified via the configuration supplied + through :func:`get_template_configs`. - The included template must be called ``_navbar.jinja2`` (e.g. ``myplugin_navbar.jinja2``) unless - overridden by the configuration supplied through :func:`get_template_configs`. + Sidebar + The left side bar containing Connection, State and Files sections can be enriched with additional sections. Note + that with the current implementations, plugins will always be located *beneath* the existing sections. - The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The - wrapper structure will have all additional classes and styles applied as specified via the configuration supplied - through :func:`get_template_configs`. + The included template must be called ``_sidebar.jinja2`` (e.g. ``myplugin_sidebar.jinja2``) unless + overridden by the configuration supplied through :func:`get_template_configs`. - Sidebar - The left side bar containing Connection, State and Files sections can be enriched with additional sections. Note - that with the current implementations, plugins will always be located *beneath* the existing sections. + The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The + wrapper divs for both the whole box as well as the content pane will have all additional classes and styles applied + as specified via the configuration supplied through :func:`get_template_configs`. - The included template must be called ``_sidebar.jinja2`` (e.g. ``myplugin_sidebar.jinja2``) unless - overridden by the configuration supplied through :func:`get_template_configs`. + Tabs + The available tabs of the main part of the interface may be extended with additional tabs originating from within + plugins. Note that with the current implementation, plugins will always be located *to the right* of the existing + tabs. - The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The - wrapper divs for both the whole box as well as the content pane will have all additional classes and styles applied - as specified via the configuration supplied through :func:`get_template_configs`. + The included template must be called ``_tab.jinja2`` (e.g. ``myplugin_tab.jinja2``) unless + overridden by the configuration supplied through :func:`get_template_configs`. - Tabs - The available tabs of the main part of the interface may be extended with additional tabs originating from within - plugins. Note that with the current implementation, plugins will always be located *to the right* of the existing - tabs. + The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The + wrapper div and the link in the navigation will have the additional classes and styles applied as specified via the + configuration supplied through :func:`get_template_configs`. - The included template must be called ``_tab.jinja2`` (e.g. ``myplugin_tab.jinja2``) unless - overridden by the configuration supplied through :func:`get_template_configs`. + Settings + Plugins may inject a dialog into the existing settings view. Note that with the current implementation, plugins + will always be listed beneath the "Plugins" header in the settings link list, ordered alphabetically after + their displayed name. - The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The - wrapper div and the link in the navigation will have the additional classes and styles applied as specified via the - configuration supplied through :func:`get_template_configs`. + The included template must be called ``_settings.jinja2`` (e.g. ``myplugin_settings.jinja2``) unless + overridden by the configuration supplied through :func:`get_template_configs`. - Settings - Plugins may inject a dialog into the existing settings view. Note that with the current implementation, plugins - will always be listed beneath the "Plugins" header in the settings link list, ordered alphabetically after - their displayed name. + The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The + wrapper div and the link in the navigation will have the additional classes and styles applied as defined via the + configuration through :func:`get_template_configs`. - The included template must be called ``_settings.jinja2`` (e.g. ``myplugin_settings.jinja2``) unless - overridden by the configuration supplied through :func:`get_template_configs`. + Wizards + Plugins may define wizard dialogs to display to the user if necessary (e.g. in case of missing information that + needs to be queried from the user to make the plugin work). Note that with the current implementation, all + wizard dialogs will be will always be sorted by their ``mandatory`` attribute (which defaults to ``False``) and then + alphabetically by their ``name``. Hence, mandatory wizard steps will come first, sorted alphabetically, then the + optional steps will follow, also alphabetically. A wizard dialog provided through a plugin will only be displayed + if the plugin reports the wizard as being required through :meth:`~octoprint.plugin.WizardPlugin.is_wizard_required`. + Please also refer to the :class:`~octoprint.plugin.WizardPlugin` mixin for further details on this. - The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The - wrapper div and the link in the navigation will have the additional classes and styles applied as defined via the - configuration through :func:`get_template_configs`. + The included template must be called ``_wizard.jinja2`` (e.g. ``myplugin_wizard.jinja2``) unless + overridden by the configuration supplied through :func:`get_template_configs`. - Wizards - Plugins may define wizard dialogs to display to the user if necessary (e.g. in case of missing information that - needs to be queried from the user to make the plugin work). Note that with the current implementation, all - wizard dialogs will be will always be sorted by their ``mandatory`` attribute (which defaults to ``False``) and then - alphabetically by their ``name``. Hence, mandatory wizard steps will come first, sorted alphabetically, then the - optional steps will follow, also alphabetically. A wizard dialog provided through a plugin will only be displayed - if the plugin reports the wizard as being required through :meth:`~octoprint.plugin.WizardPlugin.is_wizard_required`. - Please also refer to the :class:`~octoprint.plugin.WizardPlugin` mixin for further details on this. + The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. + The wrapper div and the link in the wizard navigation will have the additional classes and styles applied as defined + via the configuration supplied through :func:`get_template_configs`. - The included template must be called ``_wizard.jinja2`` (e.g. ``myplugin_wizard.jinja2``) unless - overridden by the configuration supplied through :func:`get_template_configs`. + .. note:: - The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. - The wrapper div and the link in the wizard navigation will have the additional classes and styles applied as defined - via the configuration supplied through :func:`get_template_configs`. + A note about ``mandatory`` wizard steps: In the current implementation, marking a wizard step as + mandatory will *only* make it styled accordingly. It is the task of the :ref:`view model ` + to actually prevent the user from skipping the dialog by implementing the ``onWizardTabChange`` + callback and returning ``false`` there if it is detected that the user hasn't yet filled in the + wizard step. - .. note:: - - A note about ``mandatory`` wizard steps: In the current implementation, marking a wizard step as - mandatory will *only* make it styled accordingly. It is the task of the :ref:`view model ` - to actually prevent the user from skipping the dialog by implementing the ``onWizardTabChange`` - callback and returning ``false`` there if it is detected that the user hasn't yet filled in the - wizard step. + .. versionadded:: 1.3.0 - About - Plugins may define additional panels into OctoPrint's "About" dialog. Note that with the current implementation - further about dialog panels will be sorted alphabetically by their name and sorted after the predefined ones. + About + Plugins may define additional panels into OctoPrint's "About" dialog. Note that with the current implementation + further about dialog panels will be sorted alphabetically by their name and sorted after the predefined ones. - The included template must be called ``_about.jinja2`` (e.g. ``myplugin_about.jinja2``) unless - overridden by the configuration supplied through :func:`get_template_configs`. + The included template must be called ``_about.jinja2`` (e.g. ``myplugin_about.jinja2``) unless + overridden by the configuration supplied through :func:`get_template_configs`. - The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The - wrapped div and the link in the navigation will have the additional classes and styles applied as defined via - the configuration supplied through :func:`get_template_configs`. + The template will be already wrapped into the necessary structure, plugins just need to supply the pure content. The + wrapped div and the link in the navigation will have the additional classes and styles applied as defined via + the configuration supplied through :func:`get_template_configs`. - Generic - Plugins may also inject arbitrary templates into the page of the web interface itself, e.g. in order to - add overlays or dialogs to be called from within the plugin's JavaScript code. - - .. figure:: ../images/template-plugin-types-main.png - :align: center - :alt: Template injection types in the main part of the interface - - Template injection types in the main part of the interface - - .. figure:: ../images/template-plugin-types-settings.png - :align: center - :alt: Template injection types in the settings - - Template injection types in the settings - - You can find an example for a simple plugin which injects navbar, tab and settings content into the interface in - the "helloworld" plugin in OctoPrint's :ref:`Plugin Tutorial `. - - Plugins may replace existing components, see the ``replaces`` keyword in the template configurations returned by - :meth:`.get_template_configs` below. Note that if a plugin replaces a core component, it is the plugin's - responsibility to ensure that all core functionality is still maintained. - - Plugins can also add additional template types by implementing the :ref:`octoprint.ui.web.templatetypes ` hook. - - ``TemplatePlugin`` is a :class:`~octoprint.plugin.core.ReloadNeedingPlugin`. - """ - - def get_template_configs(self): - """ - Allows configuration of injected navbar, sidebar, tab and settings templates (and also additional templates of - types specified by plugins through the :ref:`octoprint.ui.web.templatetypes ` hook). - Should be a list containing one configuration object per template to inject. Each configuration object is - represented by a dictionary which may contain the following keys: - - .. list-table:: - :widths: 5 95 - - * - type - - The template type the configuration is targeting. Possible values here are ``navbar``, ``sidebar``, - ``tab``, ``settings`` and ``generic``. Mandatory. - * - name - - The name of the component, if not set the name of the plugin will be used. The name will be visible at - a location depending on the ``type``: - - * ``navbar``: unused - * ``sidebar``: sidebar heading - * ``tab``: tab heading - * ``settings``: settings link - * ``wizard``: wizard link - * ``about``: about link - * ``generic``: unused - - * - template - - Name of the template to inject, default value depends on the ``type``: - - * ``navbar``: ``_navbar.jinja2`` - * ``sidebar``: ``_sidebar.jinja2`` - * ``tab``: ``_tab.jinja2`` - * ``settings``: ``_settings.jinja2`` - * ``wizard``: ``_wizard.jinja2`` - * ``about``: ``_about.jinja2`` - * ``generic``: ``.jinja2`` - - * - suffix - - Suffix to attach to the component identifier and the div identifier of the injected template. Will be - ``_`` if not provided and not the first template of the type, with ``index`` counting from 1 and - increasing for each template of the same type. - - Example: If your plugin with identifier ``myplugin`` defines two tab components like this: - - .. code-block:: python - - return [ - dict(type="tab", template="myplugin_first_tab.jinja2"), - dict(type="tab", template="myplugin_second_tab.jinja2") - ] - - then the first tab will have the component identifier ``plugin_myplugin`` and the second one will have - the component identifier ``plugin_myplugin_2`` (the generated divs will be ``tab_plugin_myplugin`` and - ``tab_plugin_myplugin_2`` accordingly). Notice that the first tab is *not* called ``plugin_myplugin_1`` -- - as stated above while the ``index`` used as default suffix starts counting at 1, it will not be applied - for the first component of a given type. - - If on the other hand your plugin's definition looks like this: - - .. code-block:: python - - return [ - dict(type="tab", template="myplugin_first_tab_jinja2", suffix="_1st"), - dict(type="tab", template="myplugin_second_tab_jinja2", suffix="_2nd") - ] - - then the generated component identifier will be ``plugin_myplugin_1st`` and ``plugin_myplugin_2nd`` - (and the divs will be ``tab_plugin_myplugin_1st`` and ``tab_plugin_myplugin_2nd``). - - * - div - - Id for the div containing the component. If not provided, defaults to ``_plugin_`` plus - the ``suffix`` if provided or required. - * - replaces - - Id of the component this one replaces, might be either one of the core components or a component - provided by another plugin. A list of the core component identifiers can be found - :ref:`in the configuration documentation `. The identifiers of - other plugin components always follow the format described above. - * - custom_bindings - - A boolean value indicating whether the default view model should be bound to the component (``false``) - or if a custom binding will be used by the plugin (``true``, default). - * - data_bind - - Additional knockout data bindings to apply to the component, can be used to add further behaviour to - the container based on internal state if necessary. - * - classes - - Additional classes to apply to the component, as a list of individual classes - (e.g. ``classes=["myclass", "myotherclass"]``) which will be joined into the correct format by the template engine. - * - styles - - Additional CSS styles to apply to the component, as a list of individual declarations - (e.g. ``styles=["color: red", "display: block"]``) which will be joined into the correct format by the template - engine. - - Further keys to be included in the dictionary depend on the type: - - ``sidebar`` type - - .. list-table:: - :widths: 5 95 - - * - icon - - Icon to use for the sidebar header, should be the name of a Font Awesome icon without the leading ``icon-`` part. - * - template_header - - Additional template to include in the head section of the sidebar item. For an example of this, see the additional - options included in the "Files" section. - * - classes_wrapper - - Like ``classes`` but only applied to the whole wrapper around the sidebar box. - * - classes_content - - Like ``classes`` but only applied to the content pane itself. - * - styles_wrapper - - Like ``styles`` but only applied to the whole wrapper around the sidebar box. - * - styles_content - - Like ``styles`` but only applied to the content pane itself - - ``tab`` type and ``settings`` type - - .. list-table:: - :widths: 5 95 - - * - classes_link - - Like ``classes`` but only applied to the link in the navigation. - * - classes_content - - Like ``classes`` but only applied to the content pane itself. - * - styles_link - - Like ``styles`` but only applied to the link in the navigation. - * - styles_content - - Like ``styles`` but only applied to the content pane itself. - - ``wizard`` type - - .. list-table:: - :widths: 5 95 - - * - mandatory - - Whether the wizard step is mandatory (True) or not (False). Optional, - defaults to False. If set to True, OctoPrint will sort visually mark - the step as mandatory in the UI (bold in the navigation and a little - alert) and also sort it into the first half. - - .. note:: - - As already outlined above, each template type has a default template name (i.e. the default navbar template - of a plugin is called ``_navbar.jinja2``), which may be overridden using the template configuration. - If a plugin needs to include more than one template of a given type, it needs to provide an entry for each of - those, since the implicit default template will only be included automatically if no other templates of that - type are defined. - - Example: If you have a plugin that injects two tab components, one defined in the template file - ``myplugin_tab.jinja2`` (the default template) and one in the template ``myplugin_othertab.jinja2``, you - might be tempted to just return the following configuration since one your templates is named by the default - template name: - - .. code-block:: python - - return [ - dict(type="tab", template="myplugin_othertab.jinja2") - ] - - This will only include the tab defined in ``myplugin_othertab.jinja2`` though, ``myplugin_tab.jinja2`` will - not be included automatically since the presence of a definition for the ``tab`` type overrides the automatic - injection of the default template. You'll have to include it explicitly: - - .. code-block:: python - - return [ - dict(type="tab", template="myplugin_tab.jinja2"), - dict(type="tab", template="myplugin_othertab.jinja2") - ] - - :return list: a list containing the configuration options for the plugin's injected templates - """ - return [] - - def get_template_vars(self): - """ - Defines additional template variables to include into the template renderer. Variable names will be prefixed - with ``plugin__``. - - :return dict: a dictionary containing any additional template variables to include in the renderer - """ - return dict() - - def get_template_folder(self): - """ - Defines the folder where the plugin stores its templates. Override this if your plugin stores its templates at - some other place than the ``templates`` sub folder in the plugin base directory. - - :return string: the absolute path to the folder where the plugin stores its jinja2 templates - """ - import os - return os.path.join(self._basefolder, "templates") + .. versionadded:: 1.3.0 + + Generic + Plugins may also inject arbitrary templates into the page of the web interface itself, e.g. in order to + add overlays or dialogs to be called from within the plugin's JavaScript code. + + .. figure:: ../images/template-plugin-types-main.png + :align: center + :alt: Template injection types in the main part of the interface + + Template injection types in the main part of the interface + + .. figure:: ../images/template-plugin-types-settings.png + :align: center + :alt: Template injection types in the settings + + Template injection types in the settings + + You can find an example for a simple plugin which injects navbar, tab and settings content into the interface in + the "helloworld" plugin in OctoPrint's :ref:`Plugin Tutorial `. + + Plugins may replace existing components, see the ``replaces`` keyword in the template configurations returned by + :meth:`.get_template_configs` below. Note that if a plugin replaces a core component, it is the plugin's + responsibility to ensure that all core functionality is still maintained. + + Plugins can also add additional template types by implementing the :ref:`octoprint.ui.web.templatetypes ` hook. + + ``TemplatePlugin`` is a :class:`~octoprint.plugin.core.ReloadNeedingPlugin`. + """ + + def get_template_configs(self): + """ + Allows configuration of injected navbar, sidebar, tab and settings templates (and also additional templates of + types specified by plugins through the :ref:`octoprint.ui.web.templatetypes ` hook). + Should be a list containing one configuration object per template to inject. Each configuration object is + represented by a dictionary which may contain the following keys: + + .. list-table:: + :widths: 5 95 + + * - type + - The template type the configuration is targeting. Possible values here are ``navbar``, ``sidebar``, + ``tab``, ``settings`` and ``generic``. Mandatory. + * - name + - The name of the component, if not set the name of the plugin will be used. The name will be visible at + a location depending on the ``type``: + + * ``navbar``: unused + * ``sidebar``: sidebar heading + * ``tab``: tab heading + * ``settings``: settings link + * ``wizard``: wizard link + * ``about``: about link + * ``generic``: unused + + * - template + - Name of the template to inject, default value depends on the ``type``: + + * ``navbar``: ``_navbar.jinja2`` + * ``sidebar``: ``_sidebar.jinja2`` + * ``tab``: ``_tab.jinja2`` + * ``settings``: ``_settings.jinja2`` + * ``wizard``: ``_wizard.jinja2`` + * ``about``: ``_about.jinja2`` + * ``generic``: ``.jinja2`` + + * - suffix + - Suffix to attach to the component identifier and the div identifier of the injected template. Will be + ``_`` if not provided and not the first template of the type, with ``index`` counting from 1 and + increasing for each template of the same type. + + Example: If your plugin with identifier ``myplugin`` defines two tab components like this: + + .. code-block:: python + + return [ + dict(type="tab", template="myplugin_first_tab.jinja2"), + dict(type="tab", template="myplugin_second_tab.jinja2") + ] + + then the first tab will have the component identifier ``plugin_myplugin`` and the second one will have + the component identifier ``plugin_myplugin_2`` (the generated divs will be ``tab_plugin_myplugin`` and + ``tab_plugin_myplugin_2`` accordingly). Notice that the first tab is *not* called ``plugin_myplugin_1`` -- + as stated above while the ``index`` used as default suffix starts counting at 1, it will not be applied + for the first component of a given type. + + If on the other hand your plugin's definition looks like this: + + .. code-block:: python + + return [ + dict(type="tab", template="myplugin_first_tab_jinja2", suffix="_1st"), + dict(type="tab", template="myplugin_second_tab_jinja2", suffix="_2nd") + ] + + then the generated component identifier will be ``plugin_myplugin_1st`` and ``plugin_myplugin_2nd`` + (and the divs will be ``tab_plugin_myplugin_1st`` and ``tab_plugin_myplugin_2nd``). + + * - div + - Id for the div containing the component. If not provided, defaults to ``_plugin_`` plus + the ``suffix`` if provided or required. + * - replaces + - Id of the component this one replaces, might be either one of the core components or a component + provided by another plugin. A list of the core component identifiers can be found + :ref:`in the configuration documentation `. The identifiers of + other plugin components always follow the format described above. + * - custom_bindings + - A boolean value indicating whether the default view model should be bound to the component (``false``) + or if a custom binding will be used by the plugin (``true``, default). + * - data_bind + - Additional knockout data bindings to apply to the component, can be used to add further behaviour to + the container based on internal state if necessary. + * - classes + - Additional classes to apply to the component, as a list of individual classes + (e.g. ``classes=["myclass", "myotherclass"]``) which will be joined into the correct format by the template engine. + * - styles + - Additional CSS styles to apply to the component, as a list of individual declarations + (e.g. ``styles=["color: red", "display: block"]``) which will be joined into the correct format by the template + engine. + + Further keys to be included in the dictionary depend on the type: + + ``sidebar`` type + + .. list-table:: + :widths: 5 95 + + * - icon + - Icon to use for the sidebar header, should be the name of a Font Awesome icon without the leading ``icon-`` part. + * - template_header + - Additional template to include in the head section of the sidebar item. For an example of this, see the additional + options included in the "Files" section. + * - classes_wrapper + - Like ``classes`` but only applied to the whole wrapper around the sidebar box. + * - classes_content + - Like ``classes`` but only applied to the content pane itself. + * - styles_wrapper + - Like ``styles`` but only applied to the whole wrapper around the sidebar box. + * - styles_content + - Like ``styles`` but only applied to the content pane itself + + ``tab`` type and ``settings`` type + + .. list-table:: + :widths: 5 95 + + * - classes_link + - Like ``classes`` but only applied to the link in the navigation. + * - classes_content + - Like ``classes`` but only applied to the content pane itself. + * - styles_link + - Like ``styles`` but only applied to the link in the navigation. + * - styles_content + - Like ``styles`` but only applied to the content pane itself. + + ``wizard`` type + + .. list-table:: + :widths: 5 95 + + * - mandatory + - Whether the wizard step is mandatory (True) or not (False). Optional, + defaults to False. If set to True, OctoPrint will sort visually mark + the step as mandatory in the UI (bold in the navigation and a little + alert) and also sort it into the first half. + + .. note:: + + As already outlined above, each template type has a default template name (i.e. the default navbar template + of a plugin is called ``_navbar.jinja2``), which may be overridden using the template configuration. + If a plugin needs to include more than one template of a given type, it needs to provide an entry for each of + those, since the implicit default template will only be included automatically if no other templates of that + type are defined. + + Example: If you have a plugin that injects two tab components, one defined in the template file + ``myplugin_tab.jinja2`` (the default template) and one in the template ``myplugin_othertab.jinja2``, you + might be tempted to just return the following configuration since one your templates is named by the default + template name: + + .. code-block:: python + + return [ + dict(type="tab", template="myplugin_othertab.jinja2") + ] + + This will only include the tab defined in ``myplugin_othertab.jinja2`` though, ``myplugin_tab.jinja2`` will + not be included automatically since the presence of a definition for the ``tab`` type overrides the automatic + injection of the default template. You'll have to include it explicitly: + + .. code-block:: python + + return [ + dict(type="tab", template="myplugin_tab.jinja2"), + dict(type="tab", template="myplugin_othertab.jinja2") + ] + + :return list: a list containing the configuration options for the plugin's injected templates + """ + return [] + + def get_template_vars(self): + """ + Defines additional template variables to include into the template renderer. Variable names will be prefixed + with ``plugin__``. + + :return dict: a dictionary containing any additional template variables to include in the renderer + """ + return {} + + def get_template_folder(self): + """ + Defines the folder where the plugin stores its templates. Override this if your plugin stores its templates at + some other place than the ``templates`` sub folder in the plugin base directory. + + :return string: the absolute path to the folder where the plugin stores its jinja2 templates + """ + import os + + return os.path.join(self._basefolder, "templates") class UiPlugin(OctoPrintPlugin, SortablePlugin): - """ - The ``UiPlugin`` mixin allows plugins to completely replace the UI served - by OctoPrint when requesting the main page hosted at `/`. - - OctoPrint will query whether your mixin implementation will handle a - provided request by calling :meth:`~octoprint.plugin.UiPlugin.will_handle_ui` with the Flask - `Request `_ object as - parameter. If you plugin returns `True` here, OctoPrint will next call - :meth:`~octoprint.plugin.UiPlugin.on_ui_render` with a few parameters like - - again - the Flask Request object and the render keyword arguments as - used by the default OctoPrint web interface. For more information see below. - - There are two methods used in order to allow for caching of the actual - response sent to the client. Whatever a plugin implementation returns - from the call to its :meth:`~octoprint.plugin.UiPlugin.on_ui_render` method - will be cached server side. The cache will be emptied in case of explicit - no-cache headers sent by the client, or if the ``_refresh`` query parameter - on the request exists and is set to ``true``. To prevent caching of the - response altogether, a plugin may set no-cache headers on the returned - response as well. - - ``UiPlugin`` is a :class:`~octoprint.plugin.core.SortablePlugin` with a sorting context - for :meth:`~octoprint.plugin.UiPlugin.will_handle_ui`. The first plugin to return ``True`` - for :meth:`~octoprint.plugin.UiPlugin.will_handle_ui` will be the one whose ui will be used, - no further calls to :meth:`~octoprint.plugin.UiPlugin.on_ui_render` will be performed. - - If implementations want to serve custom templates in the :meth:`~octoprint.plugin.UiPlugin.on_ui_render` - method it is recommended to also implement the :class:`~octoprint.plugin.TemplatePlugin` - mixin. - - **Example** - - What follows is a very simple example that renders a different (non functional and - only exemplary) UI if the requesting client has a UserAgent string hinting - at it being a mobile device: - - .. onlineinclude:: https://raw.githubusercontent.com/OctoPrint/Plugin-Examples/master/dummy_mobile_ui/__init__.py - :linenos: - :tab-width: 4 - :caption: `dummy_mobile_ui/__init__.py `_ - - .. onlineinclude:: https://raw.githubusercontent.com/OctoPrint/Plugin-Examples/master/dummy_mobile_ui/templates/dummy_mobile_ui_index.jinja2 - :linenos: - :tab-width: 4 - :caption: `dummy_mobile_ui/templates/dummy_mobile_ui_index.jinja2 `_ - - Try installing the above plugin ``dummy_mobile_ui`` (also available in the - `plugin examples repository `_) - into your OctoPrint instance. If you access it from a regular desktop browser, - you should still see the default UI. However if you access it from a mobile - device (make sure to not have that request the desktop version of pages!) - you should see the very simple dummy page defined above. - - **Preemptive and Runtime Caching** - - OctoPrint will also cache your custom UI for you in its server side UI cache, making sure - it only gets re-rendered if the request demands that (by having no-cache headers set) or if - the cache gets invalidated otherwise. - - In order to be able to do that, the ``UiPlugin`` offers overriding some cache specific - methods used for figuring out the source files whose modification time to use for cache invalidation - as well as override possibilities for ETag and LastModified calculation. Additionally there are - methods to allow persisting call parameters to allow for preemptively caching your UI during - server startup (basically eager caching instead of lazily waiting for the first request). - - See below for details on this. - """ - - # noinspection PyMethodMayBeStatic,PyUnusedLocal - def will_handle_ui(self, request): - """ - Called by OctoPrint to determine if the mixin implementation will be - able to handle the ``request`` provided as a parameter. - - Return ``True`` here to signal that your implementation will handle - the request and that the result of its :meth:`~octoprint.plugin.UiPlugin.on_ui_render` method - is what should be served to the user. - - The execution order of calls to this method can be influenced via the sorting context - ``UiPlugin.will_handle_ui``. - - Arguments: - request (flask.Request): A Flask `Request `_ - object. - - Returns: - bool: ``True`` if the the implementation will serve the request, - ``False`` otherwise. - """ - return False - - # noinspection PyMethodMayBeStatic,PyUnusedLocal - def on_ui_render(self, now, request, render_kwargs): - """ - Called by OctoPrint to retrieve the response to send to the client - for the ``request`` to ``/``. Only called if :meth:`~octoprint.plugin.UiPlugin.will_handle_ui` - returned ``True``. - - ``render_kwargs`` will be a dictionary (whose contents are cached) which - will contain the following key and value pairs (note that not all - key value pairs contained in the dictionary are listed here, only - those you should depend on as a plugin developer at the current time): - - .. list-table:: - :widths: 5 95 - - * - debug - - ``True`` if debug mode is enabled, ``False`` otherwise. - * - firstRun - - ``True`` if the server is being run for the first time (not - configured yet), ``False`` otherwise. - * - version - - OctoPrint's version information. This is a ``dict`` with the - following keys: - - .. list-table:: - :widths: 5 95 - - * - number - - The version number (e.g. ``x.y.z``) - * - branch - - The GIT branch from which the OctoPrint instance was built - (e.g. ``master``) - * - display - - The full human readable version string, including the - branch information (e.g. ``x.y.z (master branch)`` - - * - uiApiKey - - The UI API key to use for unauthorized API requests. This is - freshly generated on every server restart. - * - templates - - Template data to render in the UI. Will be a ``dict`` containing entries - for all known template types. - - The sub structure for each key will be as follows: - - .. list-table:: - :widths: 5 95 - - * - order - - A list of template names in the order they should appear - in the final rendered page - * - entries - - The template entry definitions to render. Depending on the - template type those are either 2-tuples of a name and a ``dict`` - or directly ``dicts`` with information regarding the - template to render. - - For the possible contents of the data ``dicts`` see the - :class:`~octoprint.plugin.TemplatePlugin` mixin. - - * - pluginNames - - A list of names of :class:`~octoprint.plugin.TemplatePlugin` - implementation that were enabled when creating the ``templates`` - value. - * - locales - - The locales for which there are translations available. - * - supportedExtensions - - The file extensions supported for uploads. - - On top of that all additional template variables as provided by :meth:`~octoprint.plugin.TemplatePlugin.get_template_vars` - will be contained in the dictionary as well. - - Arguments: - now (datetime.datetime): The datetime instance representing "now" - for this request, in case your plugin implementation needs this - information. - request (flask.Request): A Flask `Request `_ object. - render_kwargs (dict): The (cached) render keyword arguments that - would usually be provided to the core UI render function. - - Returns: - flask.Response: Should return a Flask `Response `_ - object that can be served to the requesting client directly. May be - created with ``flask.make_response`` combined with something like - ``flask.render_template``. - """ - - return None - - # noinspection PyMethodMayBeStatic - def get_ui_additional_key_data_for_cache(self): - """ - Allows to return additional data to use in the cache key. - - Returns: - list, tuple: A list or tuple of strings to use in the cache key. Will be joined by OctoPrint - using ``:`` as separator and appended to the existing ``ui:::`` - cache key. Ignored if ``None`` is returned. - """ - return None - - # noinspection PyMethodMayBeStatic - def get_ui_additional_tracked_files(self): - """ - Allows to return additional files to track for validating existing caches. By default OctoPrint - will track all declared templates, assets and translation files in the system. Additional - files can be added by a plugin through this callback. - - Returns: - list: A list of paths to additional files whose modification to track for (in)validating - the cache. Ignored if ``None`` is returned. - """ - return None - - # noinspection PyMethodMayBeStatic - def get_ui_custom_tracked_files(self): - """ - Allows to define a complete separate set of files to track for (in)validating the cache. If this - method returns something, the templates, assets and translation files won't be tracked, only the - files specified in the returned list. - - Returns: - list: A list of paths representing the only files whose modification to track for (in)validating - the cache. Ignored if ``None`` is returned. - """ - return None - - # noinspection PyMethodMayBeStatic - def get_ui_custom_etag(self): - """ - Allows to use a custom way to calculate the ETag, instead of the default method (hashing - OctoPrint's version, current ``UI_API_KEY``, tracked file paths and ``LastModified`` value). - - Returns: - str: An alternatively calculated ETag value. Ignored if ``None`` is returned (default). - """ - return None - - # noinspection PyMethodMayBeStatic - def get_ui_additional_etag(self, default_additional): - """ - Allows to provide a list of additional fields to use for ETag generation. - - By default the same list will be returned that is also used in the stock UI (and injected - via the parameter ``default_additional``). - - Arguments: - default_additional (list): The list of default fields added to the ETag of the default UI - - Returns: - (list): A list of additional fields for the ETag generation, or None - """ - return default_additional - - # noinspection PyMethodMayBeStatic - def get_ui_custom_lastmodified(self): - """ - Allows to calculate the LastModified differently than using the most recent modification - date of all tracked files. - - Returns: - int: An alternatively calculated LastModified value. Ignored if ``None`` is returned (default). - """ - return None - - # noinspection PyMethodMayBeStatic - def get_ui_preemptive_caching_enabled(self): - """ - Allows to control whether the view provided by the plugin should be preemptively - cached on server startup (default) or not. - - Have this return False if you do not want your plugin's UI to ever be preemptively cached. - - Returns: - bool: Whether to enable preemptive caching (True, default) or not (False) - """ - return True - - # noinspection PyMethodMayBeStatic - def get_ui_data_for_preemptive_caching(self): - """ - Allows defining additional data to be persisted in the preemptive cache configuration, on - top of the request path, base URL and used locale. - - Returns: - dict: Additional data to persist in the preemptive cache configuration. - """ - return None - - # noinspection PyMethodMayBeStatic - def get_ui_additional_request_data_for_preemptive_caching(self): - """ - Allows defining additional request data to persist in the preemptive cache configuration and - to use for the fake request used for populating the preemptive cache. - - Keys and values are used as keyword arguments for creating the - `Werkzeug EnvironBuilder `_ - used for creating the fake request. - - Returns: - dict: Additional request data to persist in the preemptive cache configuration and to - use for request environment construction. - """ - return None - - # noinspection PyMethodMayBeStatic - def get_ui_preemptive_caching_additional_unless(self): - """ - Allows defining additional reasons for temporarily not adding a preemptive cache record for - your plugin's UI. - - OctoPrint will call this method when processing a UI request, to determine whether to record the - access or not. If you return ``True`` here, no record will be created. - - Returns: - bool: Whether to suppress a record (True) or not (False, default) - """ - return False - - # noinspection PyMethodMayBeStatic - def get_ui_custom_template_filter(self, default_template_filter): - """ - Allows to specify a custom template filter to use for filtering the template contained in the - ``render_kwargs`` provided to the templating sub system. - - Only relevant for UiPlugins that actually utilize the stock templates of OctoPrint. - - By default simply returns the provided ``default_template_filter``. - - Arguments: - default_template_filter (callable): The default template filter used by the default UI - - Returns: - (callable) A filter function accepting the ``template_type`` and ``template_key`` of a template - and returning ``True`` to keep it and ``False`` to filter it out. If ``None`` is returned, no - filtering will take place. - """ - return default_template_filter + """ + The ``UiPlugin`` mixin allows plugins to completely replace the UI served + by OctoPrint when requesting the main page hosted at `/`. + + OctoPrint will query whether your mixin implementation will handle a + provided request by calling :meth:`~octoprint.plugin.UiPlugin.will_handle_ui` with the Flask + `Request `_ object as + parameter. If you plugin returns `True` here, OctoPrint will next call + :meth:`~octoprint.plugin.UiPlugin.on_ui_render` with a few parameters like + - again - the Flask Request object and the render keyword arguments as + used by the default OctoPrint web interface. For more information see below. + + There are two methods used in order to allow for caching of the actual + response sent to the client. Whatever a plugin implementation returns + from the call to its :meth:`~octoprint.plugin.UiPlugin.on_ui_render` method + will be cached server side. The cache will be emptied in case of explicit + no-cache headers sent by the client, or if the ``_refresh`` query parameter + on the request exists and is set to ``true``. To prevent caching of the + response altogether, a plugin may set no-cache headers on the returned + response as well. + + ``UiPlugin`` is a :class:`~octoprint.plugin.core.SortablePlugin` with a sorting context + for :meth:`~octoprint.plugin.UiPlugin.will_handle_ui`. The first plugin to return ``True`` + for :meth:`~octoprint.plugin.UiPlugin.will_handle_ui` will be the one whose ui will be used, + no further calls to :meth:`~octoprint.plugin.UiPlugin.on_ui_render` will be performed. + + If implementations want to serve custom templates in the :meth:`~octoprint.plugin.UiPlugin.on_ui_render` + method it is recommended to also implement the :class:`~octoprint.plugin.TemplatePlugin` + mixin. + + **Example** + + What follows is a very simple example that renders a different (non functional and + only exemplary) UI if the requesting client has a UserAgent string hinting + at it being a mobile device: + + .. onlineinclude:: https://raw.githubusercontent.com/OctoPrint/Plugin-Examples/master/dummy_mobile_ui/__init__.py + :linenos: + :tab-width: 4 + :caption: `dummy_mobile_ui/__init__.py `_ + + .. onlineinclude:: https://raw.githubusercontent.com/OctoPrint/Plugin-Examples/master/dummy_mobile_ui/templates/dummy_mobile_ui_index.jinja2 + :linenos: + :tab-width: 4 + :caption: `dummy_mobile_ui/templates/dummy_mobile_ui_index.jinja2 `_ + + Try installing the above plugin ``dummy_mobile_ui`` (also available in the + `plugin examples repository `_) + into your OctoPrint instance. If you access it from a regular desktop browser, + you should still see the default UI. However if you access it from a mobile + device (make sure to not have that request the desktop version of pages!) + you should see the very simple dummy page defined above. + + **Preemptive and Runtime Caching** + + OctoPrint will also cache your custom UI for you in its server side UI cache, making sure + it only gets re-rendered if the request demands that (by having no-cache headers set) or if + the cache gets invalidated otherwise. + + In order to be able to do that, the ``UiPlugin`` offers overriding some cache specific + methods used for figuring out the source files whose modification time to use for cache invalidation + as well as override possibilities for ETag and LastModified calculation. Additionally there are + methods to allow persisting call parameters to allow for preemptively caching your UI during + server startup (basically eager caching instead of lazily waiting for the first request). + + See below for details on this. + + .. versionadded:: 1.3.0 + """ + + # noinspection PyMethodMayBeStatic,PyUnusedLocal + def will_handle_ui(self, request): + """ + Called by OctoPrint to determine if the mixin implementation will be + able to handle the ``request`` provided as a parameter. + + Return ``True`` here to signal that your implementation will handle + the request and that the result of its :meth:`~octoprint.plugin.UiPlugin.on_ui_render` method + is what should be served to the user. + + The execution order of calls to this method can be influenced via the sorting context + ``UiPlugin.will_handle_ui``. + + Arguments: + request (flask.Request): A Flask `Request `_ + object. + + Returns: + bool: ``True`` if the implementation will serve the request, + ``False`` otherwise. + """ + return False + + # noinspection PyMethodMayBeStatic,PyUnusedLocal + def on_ui_render(self, now, request, render_kwargs): + """ + Called by OctoPrint to retrieve the response to send to the client + for the ``request`` to ``/``. Only called if :meth:`~octoprint.plugin.UiPlugin.will_handle_ui` + returned ``True``. + + ``render_kwargs`` will be a dictionary (whose contents are cached) which + will contain the following key and value pairs (note that not all + key value pairs contained in the dictionary are listed here, only + those you should depend on as a plugin developer at the current time): + + .. list-table:: + :widths: 5 95 + + * - debug + - ``True`` if debug mode is enabled, ``False`` otherwise. + * - firstRun + - ``True`` if the server is being run for the first time (not + configured yet), ``False`` otherwise. + * - version + - OctoPrint's version information. This is a ``dict`` with the + following keys: + + .. list-table:: + :widths: 5 95 + + * - number + - The version number (e.g. ``x.y.z``) + * - branch + - The GIT branch from which the OctoPrint instance was built + (e.g. ``master``) + * - display + - The full human readable version string, including the + branch information (e.g. ``x.y.z (master branch)`` + + * - uiApiKey + - The UI API key to use for unauthorized API requests. This is + freshly generated on every server restart. + * - templates + - Template data to render in the UI. Will be a ``dict`` containing entries + for all known template types. + + The sub structure for each key will be as follows: + + .. list-table:: + :widths: 5 95 + + * - order + - A list of template names in the order they should appear + in the final rendered page + * - entries + - The template entry definitions to render. Depending on the + template type those are either 2-tuples of a name and a ``dict`` + or directly ``dicts`` with information regarding the + template to render. + + For the possible contents of the data ``dicts`` see the + :class:`~octoprint.plugin.TemplatePlugin` mixin. + + * - pluginNames + - A list of names of :class:`~octoprint.plugin.TemplatePlugin` + implementation that were enabled when creating the ``templates`` + value. + * - locales + - The locales for which there are translations available. + * - supportedExtensions + - The file extensions supported for uploads. + + On top of that all additional template variables as provided by :meth:`~octoprint.plugin.TemplatePlugin.get_template_vars` + will be contained in the dictionary as well. + + Arguments: + now (datetime.datetime): The datetime instance representing "now" + for this request, in case your plugin implementation needs this + information. + request (flask.Request): A Flask `Request `_ object. + render_kwargs (dict): The (cached) render keyword arguments that + would usually be provided to the core UI render function. + + Returns: + flask.Response: Should return a Flask `Response `_ + object that can be served to the requesting client directly. May be + created with ``flask.make_response`` combined with something like + ``flask.render_template``. + """ + + return None + + # noinspection PyMethodMayBeStatic + def get_ui_additional_key_data_for_cache(self): + """ + Allows to return additional data to use in the cache key. + + Returns: + list, tuple: A list or tuple of strings to use in the cache key. Will be joined by OctoPrint + using ``:`` as separator and appended to the existing ``ui:::`` + cache key. Ignored if ``None`` is returned. + + .. versionadded:: 1.3.0 + """ + return None + + # noinspection PyMethodMayBeStatic + def get_ui_additional_tracked_files(self): + """ + Allows to return additional files to track for validating existing caches. By default OctoPrint + will track all declared templates, assets and translation files in the system. Additional + files can be added by a plugin through this callback. + + Returns: + list: A list of paths to additional files whose modification to track for (in)validating + the cache. Ignored if ``None`` is returned. + + .. versionadded:: 1.3.0 + """ + return None + + # noinspection PyMethodMayBeStatic + def get_ui_custom_tracked_files(self): + """ + Allows to define a complete separate set of files to track for (in)validating the cache. If this + method returns something, the templates, assets and translation files won't be tracked, only the + files specified in the returned list. + + Returns: + list: A list of paths representing the only files whose modification to track for (in)validating + the cache. Ignored if ``None`` is returned. + + .. versionadded:: 1.3.0 + """ + return None + + # noinspection PyMethodMayBeStatic + def get_ui_custom_etag(self): + """ + Allows to use a custom way to calculate the ETag, instead of the default method (hashing + OctoPrint's version, tracked file paths and ``LastModified`` value). + + Returns: + str: An alternatively calculated ETag value. Ignored if ``None`` is returned (default). + + .. versionadded:: 1.3.0 + """ + return None + + # noinspection PyMethodMayBeStatic + def get_ui_additional_etag(self, default_additional): + """ + Allows to provide a list of additional fields to use for ETag generation. + + By default the same list will be returned that is also used in the stock UI (and injected + via the parameter ``default_additional``). + + Arguments: + default_additional (list): The list of default fields added to the ETag of the default UI + + Returns: + (list): A list of additional fields for the ETag generation, or None + + .. versionadded:: 1.3.0 + """ + return default_additional + + # noinspection PyMethodMayBeStatic + def get_ui_custom_lastmodified(self): + """ + Allows to calculate the LastModified differently than using the most recent modification + date of all tracked files. + + Returns: + int: An alternatively calculated LastModified value. Ignored if ``None`` is returned (default). + + .. versionadded:: 1.3.0 + """ + return None + + # noinspection PyMethodMayBeStatic + def get_ui_preemptive_caching_enabled(self): + """ + Allows to control whether the view provided by the plugin should be preemptively + cached on server startup (default) or not. + + Have this return False if you do not want your plugin's UI to ever be preemptively cached. + + Returns: + bool: Whether to enable preemptive caching (True, default) or not (False) + """ + return True + + # noinspection PyMethodMayBeStatic + def get_ui_data_for_preemptive_caching(self): + """ + Allows defining additional data to be persisted in the preemptive cache configuration, on + top of the request path, base URL and used locale. + + Returns: + dict: Additional data to persist in the preemptive cache configuration. + + .. versionadded:: 1.3.0 + """ + return None + + # noinspection PyMethodMayBeStatic + def get_ui_additional_request_data_for_preemptive_caching(self): + """ + Allows defining additional request data to persist in the preemptive cache configuration and + to use for the fake request used for populating the preemptive cache. + + Keys and values are used as keyword arguments for creating the + `Werkzeug EnvironBuilder `_ + used for creating the fake request. + + Returns: + dict: Additional request data to persist in the preemptive cache configuration and to + use for request environment construction. + + .. versionadded:: 1.3.0 + """ + return None + + # noinspection PyMethodMayBeStatic + def get_ui_preemptive_caching_additional_unless(self): + """ + Allows defining additional reasons for temporarily not adding a preemptive cache record for + your plugin's UI. + + OctoPrint will call this method when processing a UI request, to determine whether to record the + access or not. If you return ``True`` here, no record will be created. + + Returns: + bool: Whether to suppress a record (True) or not (False, default) + + .. versionadded:: 1.3.0 + """ + return False + + # noinspection PyMethodMayBeStatic + def get_ui_custom_template_filter(self, default_template_filter): + """ + Allows to specify a custom template filter to use for filtering the template contained in the + ``render_kwargs`` provided to the templating sub system. + + Only relevant for UiPlugins that actually utilize the stock templates of OctoPrint. + + By default simply returns the provided ``default_template_filter``. + + Arguments: + default_template_filter (callable): The default template filter used by the default UI + + Returns: + (callable) A filter function accepting the ``template_type`` and ``template_key`` of a template + and returning ``True`` to keep it and ``False`` to filter it out. If ``None`` is returned, no + filtering will take place. + + .. versionadded:: 1.3.0 + """ + return default_template_filter + + # noinspection PyMethodMayBeStatic + def get_ui_permissions(self): + """ + Determines a list of permissions that need to be on the current user session. If + these requirements are not met, OctoPrint will instead redirect to a login + screen. + + Plugins may override this with their own set of permissions. Returning an empty + list will instruct OctoPrint to never show a login dialog when this UiPlugin's + view renders, in which case it will fall to your plugin to implement its own + login logic. + + Returns: + (list) A list of permissions which to check the current user session against. + May be empty to indicate that no permission checks should be made by OctoPrint. + + .. versionadded: 1.5.0 + """ + from octoprint.access.permissions import Permissions + + return [Permissions.STATUS, Permissions.SETTINGS_READ] + class WizardPlugin(OctoPrintPlugin, ReloadNeedingPlugin): - """ - The ``WizardPlugin`` mixin allows plugins to report to OctoPrint whether - the ``wizard`` templates they define via the :class:`~octoprint.plugin.TemplatePlugin` - should be displayed to the user, what details to provide to their respective - wizard frontend components and what to do when the wizard is finished - by the user. - - OctoPrint will only display such wizard dialogs to the user which belong - to plugins that - - * report ``True`` in their :func:`is_wizard_required` method and - * have not yet been shown to the user in the version currently being reported - by the :meth:`~octoprint.plugin.WizardPlugin.get_wizard_version` method - - Example: If a plugin with the identifier ``myplugin`` has a specific - setting ``some_key`` it needs to have filled by the user in order to be - able to work at all, it would probably test for that setting's value in - the :meth:`~octoprint.plugin.WizardPlugin.is_wizard_required` method and - return ``True`` if the value is unset: - - .. code-block:: python - - class MyPlugin(octoprint.plugin.SettingsPlugin, - octoprint.plugin.TemplatePlugin, - octoprint.plugin.WizardPlugin): - - def get_default_settings(self): - return dict(some_key=None) - - def is_wizard_required(self): - return self._settings.get(["some_key"]) is None - - OctoPrint will then display the wizard dialog provided by the plugin through - the :class:`TemplatePlugin` mixin. Once the user finishes the wizard on the - frontend, OctoPrint will store that it already showed the wizard of ``myplugin`` - in the version reported by :meth:`~octoprint.plugin.WizardPlugin.get_wizard_version` - - here ``None`` since that is the default value returned by that function - and the plugin did not override it. - - If the plugin in a later version needs another setting from the user in order - to function, it will also need to change the reported version in order to - have OctoPrint reshow the dialog. E.g. - - .. code-block:: python - - class MyPlugin(octoprint.plugin.SettingsPlugin, - octoprint.plugin.TemplatePlugin, - octoprint.plugin.WizardPlugin): - - def get_default_settings(self): - return dict(some_key=None, some_other_key=None) - - def is_wizard_required(self): - some_key_unset = self._settings.get(["some_key"]) is None - some_other_key_unset = self._settings.get(["some_other_key"]) is None - - return some_key_unset or some_other_key_unset - - def get_wizard_version(self): - return 1 - - ``WizardPlugin`` is a :class:`~octoprint.plugin.core.ReloadNeedingPlugin`. - """ - - # noinspection PyMethodMayBeStatic - def is_wizard_required(self): - """ - Allows the plugin to report whether it needs to display a wizard to the - user or not. - - Defaults to ``False``. - - OctoPrint will only include those wizards from plugins which are reporting - their wizards as being required through this method returning ``True``. - Still, if OctoPrint already displayed that wizard in the same version - to the user once it won't be displayed again regardless whether this - method returns ``True`` or not. - """ - return False - - # noinspection PyMethodMayBeStatic - def get_wizard_version(self): - """ - The version of this plugin's wizard. OctoPrint will only display a wizard - of the same plugin and wizard version once to the user. After they - finish the wizard, OctoPrint will remember that it already showed this - wizard in this particular version and not reshow it. - - If a plugin needs to show its wizard to the user again (e.g. because - of changes in the required settings), increasing this value is the - way to notify OctoPrint of these changes. - - Returns: - int or None: an int signifying the current wizard version, should be incremented by plugins whenever there - are changes to the plugin that might necessitate reshowing the wizard if it is required. ``None`` - will also be accepted and lead to the wizard always be ignored unless it has never been finished - so far - """ - return None - - # noinspection PyMethodMayBeStatic - def get_wizard_details(self): - """ - Called by OctoPrint when the wizard wrapper dialog is shown. Allows the plugin to return data - that will then be made available to the view models via the view model callback ``onWizardDetails``. - - Use this if your plugin's view model that handles your wizard dialog needs additional - data to perform its task. - - Returns: - dict: a dictionary containing additional data to provide to the frontend. Whatever the plugin - returns here will be made available on the wizard API under the plugin's identifier - """ - return dict() - - # noinspection PyMethodMayBeStatic,PyUnusedLocal - def on_wizard_finish(self, handled): - """ - Called by OctoPrint whenever the user finishes a wizard session. - - The ``handled`` parameter will indicate whether that plugin's wizard was - included in the wizard dialog presented to the user (so the plugin providing - it was reporting that the wizard was required and the wizard plus version was not - ignored/had already been seen). - - Use this to do any clean up tasks necessary after wizard completion. - - Arguments: - handled (bool): True if the plugin's wizard was previously reported as - required, not ignored and thus presented to the user, - False otherwise - """ - pass - - # noinspection PyProtectedMember - @classmethod - def is_wizard_ignored(cls, seen_wizards, implementation): - """ - Determines whether the provided implementation is ignored based on the - provided information about already seen wizards and their versions or not. - - A wizard is ignored if - - * the current and seen versions are identical - * the current version is None and the seen version is not - * the current version is less or equal than the seen one - - .. code-block:: none - - | current | - | N | 1 | 2 | N = None - ----+---+---+---+ X = ignored - s N | X | | | - e --+---+---+---+ - e 1 | X | X | | - n --+---+---+---+ - 2 | X | X | X | - ----+---+---+---+ - - Arguments: - seen_wizards (dict): A dictionary with information about already seen - wizards and their versions. Mappings from the identifiers of - the plugin providing the wizard to the reported wizard - version (int or None) that was already seen by the user. - implementation (object): The plugin implementation to check. - - Returns: - bool: False if the provided ``implementation`` is either not a :class:`WizardPlugin` - or has not yet been seen (in this version), True otherwise - """ - - if not isinstance(implementation, cls): - return False - - name = implementation._identifier - if not name in seen_wizards: - return False - - seen = seen_wizards[name] - wizard_version = implementation.get_wizard_version() - - current = None - if wizard_version is not None: - try: - current = int(wizard_version) - except ValueError as e: - import logging - logging.getLogger(__name__).log("WizardPlugin {} returned invalid value {} for wizard version: {}".format(name, wizard_version, str(e))) - - return (current == seen) \ - or (current is None and seen is not None) \ - or (current <= seen) + """ + The ``WizardPlugin`` mixin allows plugins to report to OctoPrint whether + the ``wizard`` templates they define via the :class:`~octoprint.plugin.TemplatePlugin` + should be displayed to the user, what details to provide to their respective + wizard frontend components and what to do when the wizard is finished + by the user. + + OctoPrint will only display such wizard dialogs to the user which belong + to plugins that + + * report ``True`` in their :func:`is_wizard_required` method and + * have not yet been shown to the user in the version currently being reported + by the :meth:`~octoprint.plugin.WizardPlugin.get_wizard_version` method + + Example: If a plugin with the identifier ``myplugin`` has a specific + setting ``some_key`` it needs to have filled by the user in order to be + able to work at all, it would probably test for that setting's value in + the :meth:`~octoprint.plugin.WizardPlugin.is_wizard_required` method and + return ``True`` if the value is unset: + + .. code-block:: python + + class MyPlugin(octoprint.plugin.SettingsPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.WizardPlugin): + + def get_default_settings(self): + return dict(some_key=None) + + def is_wizard_required(self): + return self._settings.get(["some_key"]) is None + + OctoPrint will then display the wizard dialog provided by the plugin through + the :class:`TemplatePlugin` mixin. Once the user finishes the wizard on the + frontend, OctoPrint will store that it already showed the wizard of ``myplugin`` + in the version reported by :meth:`~octoprint.plugin.WizardPlugin.get_wizard_version` + - here ``None`` since that is the default value returned by that function + and the plugin did not override it. + + If the plugin in a later version needs another setting from the user in order + to function, it will also need to change the reported version in order to + have OctoPrint reshow the dialog. E.g. + + .. code-block:: python + + class MyPlugin(octoprint.plugin.SettingsPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.WizardPlugin): + + def get_default_settings(self): + return dict(some_key=None, some_other_key=None) + + def is_wizard_required(self): + some_key_unset = self._settings.get(["some_key"]) is None + some_other_key_unset = self._settings.get(["some_other_key"]) is None + + return some_key_unset or some_other_key_unset + + def get_wizard_version(self): + return 1 + + ``WizardPlugin`` is a :class:`~octoprint.plugin.core.ReloadNeedingPlugin`. + """ + + # noinspection PyMethodMayBeStatic + def is_wizard_required(self): + """ + Allows the plugin to report whether it needs to display a wizard to the + user or not. + + Defaults to ``False``. + + OctoPrint will only include those wizards from plugins which are reporting + their wizards as being required through this method returning ``True``. + Still, if OctoPrint already displayed that wizard in the same version + to the user once it won't be displayed again regardless whether this + method returns ``True`` or not. + """ + return False + + # noinspection PyMethodMayBeStatic + def get_wizard_version(self): + """ + The version of this plugin's wizard. OctoPrint will only display a wizard + of the same plugin and wizard version once to the user. After they + finish the wizard, OctoPrint will remember that it already showed this + wizard in this particular version and not reshow it. + + If a plugin needs to show its wizard to the user again (e.g. because + of changes in the required settings), increasing this value is the + way to notify OctoPrint of these changes. + + Returns: + int or None: an int signifying the current wizard version, should be incremented by plugins whenever there + are changes to the plugin that might necessitate reshowing the wizard if it is required. ``None`` + will also be accepted and lead to the wizard always be ignored unless it has never been finished + so far + """ + return None + + # noinspection PyMethodMayBeStatic + def get_wizard_details(self): + """ + Called by OctoPrint when the wizard wrapper dialog is shown. Allows the plugin to return data + that will then be made available to the view models via the view model callback ``onWizardDetails``. + + Use this if your plugin's view model that handles your wizard dialog needs additional + data to perform its task. + + Returns: + dict: a dictionary containing additional data to provide to the frontend. Whatever the plugin + returns here will be made available on the wizard API under the plugin's identifier + """ + return {} + + # noinspection PyMethodMayBeStatic,PyUnusedLocal + def on_wizard_finish(self, handled): + """ + Called by OctoPrint whenever the user finishes a wizard session. + + The ``handled`` parameter will indicate whether that plugin's wizard was + included in the wizard dialog presented to the user (so the plugin providing + it was reporting that the wizard was required and the wizard plus version was not + ignored/had already been seen). + + Use this to do any clean up tasks necessary after wizard completion. + + Arguments: + handled (bool): True if the plugin's wizard was previously reported as + required, not ignored and thus presented to the user, + False otherwise + """ + pass + + # noinspection PyProtectedMember + @classmethod + def is_wizard_ignored(cls, seen_wizards, implementation): + """ + Determines whether the provided implementation is ignored based on the + provided information about already seen wizards and their versions or not. + + A wizard is ignored if + + * the current and seen versions are identical + * the current version is None and the seen version is not + * the seen version is not None and the current version is less or equal than the seen one + + .. code-block:: none + + | current | + | N | 1 | 2 | N = None + ----+---+---+---+ X = ignored + s N | X | | | + e --+---+---+---+ + e 1 | X | X | | + n --+---+---+---+ + 2 | X | X | X | + ----+---+---+---+ + + Arguments: + seen_wizards (dict): A dictionary with information about already seen + wizards and their versions. Mappings from the identifiers of + the plugin providing the wizard to the reported wizard + version (int or None) that was already seen by the user. + implementation (object): The plugin implementation to check. + + Returns: + bool: False if the provided ``implementation`` is either not a :class:`WizardPlugin` + or has not yet been seen (in this version), True otherwise + """ + + if not isinstance(implementation, cls): + return False + + name = implementation._identifier + if name not in seen_wizards: + return False + + seen = seen_wizards[name] + wizard_version = implementation.get_wizard_version() + + current = None + if wizard_version is not None: + try: + current = int(wizard_version) + except ValueError as e: + import logging + + logging.getLogger(__name__).log( + "WizardPlugin {} returned invalid value {} for wizard version: {}".format( + name, wizard_version, str(e) + ) + ) + + return ( + (current == seen) + or (current is None and seen is not None) + or (seen is not None and current <= seen) + ) class SimpleApiPlugin(OctoPrintPlugin): - """ - Utilizing the ``SimpleApiPlugin`` mixin plugins may implement a simple API based around one GET resource and one - resource accepting JSON commands POSTed to it. This is the easy alternative for plugin's which don't need the - full power of a `Flask Blueprint `_ that the :class:`BlueprintPlugin` - mixin offers. + """ + Utilizing the ``SimpleApiPlugin`` mixin plugins may implement a simple API based around one GET resource and one + resource accepting JSON commands POSTed to it. This is the easy alternative for plugin's which don't need the + full power of a `Flask Blueprint `_ that the :class:`BlueprintPlugin` + mixin offers. - Use this mixin if all you need to do is return some kind of dynamic data to your plugin from the backend - and/or want to react to simple commands which boil down to a type of command and a few flat parameters - supplied with it. + Use this mixin if all you need to do is return some kind of dynamic data to your plugin from the backend + and/or want to react to simple commands which boil down to a type of command and a few flat parameters + supplied with it. - The simple API constructed by OctoPrint for you will be made available under ``/api/plugin//``. - OctoPrint will do some preliminary request validation for your defined commands, making sure the request body is in - the correct format (content type must be JSON) and contains all obligatory parameters for your command. + The simple API constructed by OctoPrint for you will be made available under ``/api/plugin//``. + OctoPrint will do some preliminary request validation for your defined commands, making sure the request body is in + the correct format (content type must be JSON) and contains all obligatory parameters for your command. - Let's take a look at a small example for such a simple API and how you would go about calling it. + Let's take a look at a small example for such a simple API and how you would go about calling it. - Take this example of a plugin registered under plugin identifier ``mysimpleapiplugin``: + Take this example of a plugin registered under plugin identifier ``mysimpleapiplugin``: - .. code-block:: python - :linenos: + .. code-block:: python + :linenos: - import octoprint.plugin + import octoprint.plugin - import flask + import flask - class MySimpleApiPlugin(octoprint.plugin.SimpleApiPlugin): - def get_api_commands(self): - return dict( - command1=[], - command2=["some_parameter"] - ) + class MySimpleApiPlugin(octoprint.plugin.SimpleApiPlugin): + def get_api_commands(self): + return dict( + command1=[], + command2=["some_parameter"] + ) - def on_api_command(self, command, data): - import flask - if command == "command1": - parameter = "unset" - if "parameter" in data: - parameter = "set" - self._logger.info("command1 called, parameter is {parameter}".format(**locals())) - elif command == "command2": - self._logger.info("command2 called, some_parameter is {some_parameter}".format(**data)) + def on_api_command(self, command, data): + import flask + if command == "command1": + parameter = "unset" + if "parameter" in data: + parameter = "set" + self._logger.info("command1 called, parameter is {parameter}".format(**locals())) + elif command == "command2": + self._logger.info("command2 called, some_parameter is {some_parameter}".format(**data)) - def on_api_get(self, request): - return flask.jsonify(foo="bar") + def on_api_get(self, request): + return flask.jsonify(foo="bar") - __plugin_implementation__ = MySimpleApiPlugin() + __plugin_implementation__ = MySimpleApiPlugin() - Our plugin defines two commands, ``command1`` with no mandatory parameters and ``command2`` with one - mandatory parameter ``some_parameter``. + Our plugin defines two commands, ``command1`` with no mandatory parameters and ``command2`` with one + mandatory parameter ``some_parameter``. - ``command1`` can also accept an optional parameter ``parameter``, and will log whether - that parameter was set or unset. ``command2`` will log the content of the mandatory ``some_parameter`` parameter. + ``command1`` can also accept an optional parameter ``parameter``, and will log whether + that parameter was set or unset. ``command2`` will log the content of the mandatory ``some_parameter`` parameter. - A valid POST request for ``command2`` sent to ``/api/plugin/mysimpleapiplugin`` would look like this: + A valid POST request for ``command2`` sent to ``/api/plugin/mysimpleapiplugin`` would look like this: - .. sourcecode:: http + .. sourcecode:: http - POST /api/plugin/mysimpleapiplugin HTTP/1.1 - Host: example.com - Content-Type: application/json - X-Api-Key: abcdef... + POST /api/plugin/mysimpleapiplugin HTTP/1.1 + Host: example.com + Content-Type: application/json + X-Api-Key: abcdef... - { - "command": "command2", - "some_parameter": "some_value", - "some_optional_parameter": 2342 - } + { + "command": "command2", + "some_parameter": "some_value", + "some_optional_parameter": 2342 + } - which would produce a response like this: + which would produce a response like this: - .. sourcecode:: http + .. sourcecode:: http - HTTP/1.1 204 No Content + HTTP/1.1 204 No Content - and print something like this line to ``octoprint.log``:: + and print something like this line to ``octoprint.log``:: - 2015-02-12 17:40:21,140 - octoprint.plugins.mysimpleapiplugin - INFO - command2 called, some_parameter is some_value + 2015-02-12 17:40:21,140 - octoprint.plugins.mysimpleapiplugin - INFO - command2 called, some_parameter is some_value - A GET request on our plugin's simple API resource will only return a JSON document like this: + A GET request on our plugin's simple API resource will only return a JSON document like this: - .. sourcecode:: http + .. sourcecode:: http - HTTP/1.1 200 Ok - Content-Type: application/json + HTTP/1.1 200 Ok + Content-Type: application/json - { - "foo": "bar" - } - """ + { + "foo": "bar" + } + """ - # noinspection PyMethodMayBeStatic - def get_api_commands(self): - """ - Return a dictionary here with the keys representing the accepted commands and the values being lists of - mandatory parameter names. - """ - return None + # noinspection PyMethodMayBeStatic + def get_api_commands(self): + """ + Return a dictionary here with the keys representing the accepted commands and the values being lists of + mandatory parameter names. + """ + return None - # noinspection PyMethodMayBeStatic - def is_api_adminonly(self): - """ - Return True if the API is only available to users having the admin role. - """ - return False + # noinspection PyMethodMayBeStatic + def is_api_adminonly(self): + """ + Return True if the API is only available to users having the admin role. + """ + return False - # noinspection PyMethodMayBeStatic - def on_api_command(self, command, data): - """ - Called by OctoPrint upon a POST request to ``/api/plugin/``. ``command`` will contain one of - the commands as specified via :func:`get_api_commands`, ``data`` will contain the full request body parsed - from JSON into a Python dictionary. Note that this will also contain the ``command`` attribute itself. For the - example given above, for the ``command2`` request the ``data`` received by the plugin would be equal to - ``dict(command="command2", some_parameter="some_value")``. + # noinspection PyMethodMayBeStatic + def on_api_command(self, command, data): + """ + Called by OctoPrint upon a POST request to ``/api/plugin/``. ``command`` will contain one of + the commands as specified via :func:`get_api_commands`, ``data`` will contain the full request body parsed + from JSON into a Python dictionary. Note that this will also contain the ``command`` attribute itself. For the + example given above, for the ``command2`` request the ``data`` received by the plugin would be equal to + ``dict(command="command2", some_parameter="some_value")``. - If your plugin returns nothing here, OctoPrint will return an empty response with return code ``204 No content`` - for you. You may also return regular responses as you would return from any Flask view here though, e.g. - ``return flask.jsonify(result="some json result")`` or ``return flask.make_response("Not found", 404)``. + If your plugin returns nothing here, OctoPrint will return an empty response with return code ``204 No content`` + for you. You may also return regular responses as you would return from any Flask view here though, e.g. + ``return flask.jsonify(result="some json result")`` or ``flask.abort(404)``. - :param string command: the command with which the resource was called - :param dict data: the full request body of the POST request parsed from JSON into a Python dictionary - :return: ``None`` in which case OctoPrint will generate a ``204 No content`` response with empty body, or optionally - a proper Flask response. - """ - return None + :param string command: the command with which the resource was called + :param dict data: the full request body of the POST request parsed from JSON into a Python dictionary + :return: ``None`` in which case OctoPrint will generate a ``204 No content`` response with empty body, or optionally + a proper Flask response. + """ + return None - # noinspection PyMethodMayBeStatic - def on_api_get(self, request): - """ - Called by OctoPrint upon a GET request to ``/api/plugin/``. ``request`` will contain the - received `Flask request object `_ which you may evaluate - for additional arguments supplied with the request. + # noinspection PyMethodMayBeStatic + def on_api_get(self, request): + """ + Called by OctoPrint upon a GET request to ``/api/plugin/``. ``request`` will contain the + received `Flask request object `_ which you may evaluate + for additional arguments supplied with the request. - If your plugin returns nothing here, OctoPrint will return an empty response with return code ``204 No content`` - for you. You may also return regular responses as you would return from any Flask view here though, e.g. - ``return flask.jsonify(result="some json result")`` or ``return flask.make_response("Not found", 404)``. + If your plugin returns nothing here, OctoPrint will return an empty response with return code ``204 No content`` + for you. You may also return regular responses as you would return from any Flask view here though, e.g. + ``return flask.jsonify(result="some json result")`` or ``flask.abort(404)``. - :param request: the Flask request object - :return: ``None`` in which case OctoPrint will generate a ``204 No content`` response with empty body, or optionally - a proper Flask response. - """ - return None + :param request: the Flask request object + :return: ``None`` in which case OctoPrint will generate a ``204 No content`` response with empty body, or optionally + a proper Flask response. + """ + return None class BlueprintPlugin(OctoPrintPlugin, RestartNeedingPlugin): - """ - The ``BlueprintPlugin`` mixin allows plugins to define their own full fledged endpoints for whatever purpose, - be it a more sophisticated API than what is possible via the :class:`SimpleApiPlugin` or a custom web frontend. - - The mechanism at work here is `Flask's `_ own `Blueprint mechanism `_. - - The mixin automatically creates a blueprint for you that will be registered under ``/plugin//``. - All you need to do is decorate all of your view functions with the :func:`route` decorator, - which behaves exactly the same like Flask's regular ``route`` decorators. Example: - - .. code-block:: python - :linenos: - - import octoprint.plugin - import flask - - class MyBlueprintPlugin(octoprint.plugin.BlueprintPlugin): - @octoprint.plugin.BlueprintPlugin.route("/echo", methods=["GET"]) - def myEcho(self): - if not "text" in flask.request.values: - return flask.make_response("Expected a text to echo back.", 400) - return flask.request.values["text"] - - __plugin_implementation__ = MyBlueprintPlugin() - - Your blueprint will be published by OctoPrint under the base URL ``/plugin//``, so the above - example of a plugin with the identifier "myblueprintplugin" would be reachable under - ``/plugin/myblueprintplugin/echo``. - - Just like with regular blueprints you'll be able to create URLs via ``url_for``, just use the prefix - ``plugin..``, e.g.: - - .. code-block:: python - - flask.url_for("plugin.myblueprintplugin.myEcho") # will return "/plugin/myblueprintplugin/echo" - - - ``BlueprintPlugin`` implements :class:`~octoprint.plugins.core.RestartNeedingPlugin`. - """ - - @staticmethod - def route(rule, **options): - """ - A decorator to mark view methods in your BlueprintPlugin subclass. Works just the same as Flask's - own ``route`` decorator available on blueprints. - - See `the documentation for flask.Blueprint.route `_ - and `the documentation for flask.Flask.route `_ for more - information. - """ - - from collections import defaultdict - def decorator(f): - # We attach the decorator parameters directly to the function object, because that's the only place - # we can access right now. - # This neat little trick was adapter from the Flask-Classy project: https://pythonhosted.org/Flask-Classy/ - if not hasattr(f, "_blueprint_rules") or f._blueprint_rules is None: - f._blueprint_rules = defaultdict(list) - f._blueprint_rules[f.__name__].append((rule, options)) - return f - return decorator - - @staticmethod - def errorhandler(code_or_exception): - """ - A decorator to mark errorhandlings methods in your BlueprintPlugin subclass. Works just the same as Flask's - own ``errorhandler`` decorator available on blueprints. - - See `the documentation for flask.Blueprint.errorhandler `_ - and `the documentation for flask.Flask.errorhandler `_ for more - information. - """ - from collections import defaultdict - def decorator(f): - if not hasattr(f, "_blueprint_error_handler") or f._blueprint_error_handler is None: - f._blueprint_error_handler = defaultdict(list) - f._blueprint_error_handler[f.__name__].append(code_or_exception) - return f - return decorator - - # noinspection PyProtectedMember - def get_blueprint(self): - """ - Creates and returns the blueprint for your plugin. Override this if you want to define and handle your blueprint yourself. - - This method will only be called once during server initialization. - - :return: the blueprint ready to be registered with Flask - """ - - if hasattr(self, "_blueprint"): - # if we already constructed the blueprint and hence have it cached, - # return that instance - we don't want to instance it multiple times - return self._blueprint - - import flask - kwargs = self.get_blueprint_kwargs() - blueprint = flask.Blueprint("plugin." + self._identifier, self._identifier, **kwargs) - - # we now iterate over all members of ourselves and look if we find an attribute - # that has data originating from one of our decorators - we ignore anything - # starting with a _ to only handle public stuff - for member in [member for member in dir(self) if not member.startswith("_")]: - f = getattr(self, member) - - if hasattr(f, "_blueprint_rules") and member in f._blueprint_rules: - # this attribute was annotated with our @route decorator - for blueprint_rule in f._blueprint_rules[member]: - rule, options = blueprint_rule - blueprint.add_url_rule(rule, options.pop("endpoint", f.__name__), view_func=f, **options) - - if hasattr(f, "_blueprint_error_handler") and member in f._blueprint_error_handler: - # this attribute was annotated with our @error_handler decorator - for code_or_exception in f._blueprint_error_handler[member]: - blueprint.errorhandler(code_or_exception)(f) - - # cache and return the blueprint object - self._blueprint = blueprint - return blueprint - - def get_blueprint_kwargs(self): - """ - Override this if you want your blueprint constructed with additional options such as ``static_folder``, - ``template_folder``, etc. - - Defaults to the blueprint's ``static_folder`` and ``template_folder`` to be set to the plugin's basefolder - plus ``/static`` or respectively ``/templates``, or -- if the plugin also implements :class:`AssetPlugin` and/or - :class:`TemplatePlugin` -- the paths provided by ``get_asset_folder`` and ``get_template_folder`` respectively. - """ - import os - - if isinstance(self, AssetPlugin): - static_folder = self.get_asset_folder() - else: - static_folder = os.path.join(self._basefolder, "static") - - if isinstance(self, TemplatePlugin): - template_folder = self.get_template_folder() - else: - template_folder = os.path.join(self._basefolder, "templates") - - return dict( - static_folder=static_folder, - template_folder=template_folder - ) - - # noinspection PyMethodMayBeStatic - def is_blueprint_protected(self): - """ - Whether a valid API key is needed to access the blueprint (the default) or not. Note that this only restricts - access to the blueprint's dynamic methods, static files are always accessible without API key. - """ - - return True + """ + The ``BlueprintPlugin`` mixin allows plugins to define their own full fledged endpoints for whatever purpose, + be it a more sophisticated API than what is possible via the :class:`SimpleApiPlugin` or a custom web frontend. + The mechanism at work here is `Flask's `_ own `Blueprint mechanism `_. -class SettingsPlugin(OctoPrintPlugin): - """ - Including the ``SettingsPlugin`` mixin allows plugins to store and retrieve their own settings within OctoPrint's - configuration. + The mixin automatically creates a blueprint for you that will be registered under ``/plugin//``. + All you need to do is decorate all of your view functions with the :func:`route` decorator, + which behaves exactly the same like Flask's regular ``route`` decorators. Example: - Plugins including the mixing will get injected an additional property ``self._settings`` which is an instance of - :class:`PluginSettingsManager` already properly initialized for use by the plugin. In order for the manager to - know about the available settings structure and default values upon initialization, implementing plugins will need - to provide a dictionary with the plugin's default settings through overriding the method :func:`get_settings_defaults`. - The defined structure will then be available to access through the settings manager available as ``self._settings``. + .. code-block:: python + :linenos: - If your plugin needs to react to the change of specific configuration values on the fly, e.g. to adjust the log level - of a logger when the user changes a corresponding flag via the settings dialog, you can override the - :func:`on_settings_save` method and wrap the call to the implementation from the parent class with retrieval of the - old and the new value and react accordingly. + import octoprint.plugin + import flask - Example: + class MyBlueprintPlugin(octoprint.plugin.BlueprintPlugin): + @octoprint.plugin.BlueprintPlugin.route("/echo", methods=["GET"]) + def myEcho(self): + if not "text" in flask.request.values: + abort(400, description="Expected a text to echo back.") + return flask.request.values["text"] - .. code-block:: python + __plugin_implementation__ = MyBlueprintPlugin() + + Your blueprint will be published by OctoPrint under the base URL ``/plugin//``, so the above + example of a plugin with the identifier "myblueprintplugin" would be reachable under + ``/plugin/myblueprintplugin/echo``. + + Just like with regular blueprints you'll be able to create URLs via ``url_for``, just use the prefix + ``plugin..``, e.g.: + + .. code-block:: python + + flask.url_for("plugin.myblueprintplugin.myEcho") # will return "/plugin/myblueprintplugin/echo" + + + ``BlueprintPlugin`` implements :class:`~octoprint.plugins.core.RestartNeedingPlugin`. + """ + + @staticmethod + def route(rule, **options): + """ + A decorator to mark view methods in your BlueprintPlugin subclass. Works just the same as Flask's + own ``route`` decorator available on blueprints. + + See `the documentation for flask.Blueprint.route `_ + and `the documentation for flask.Flask.route `_ for more + information. + """ + + from collections import defaultdict + + def decorator(f): + # We attach the decorator parameters directly to the function object, because that's the only place + # we can access right now. + # This neat little trick was adapter from the Flask-Classy project: https://pythonhosted.org/Flask-Classy/ + if not hasattr(f, "_blueprint_rules") or f._blueprint_rules is None: + f._blueprint_rules = defaultdict(list) + f._blueprint_rules[f.__name__].append((rule, options)) + return f + + return decorator + + @staticmethod + def errorhandler(code_or_exception): + """ + A decorator to mark errorhandlings methods in your BlueprintPlugin subclass. Works just the same as Flask's + own ``errorhandler`` decorator available on blueprints. + + See `the documentation for flask.Blueprint.errorhandler `_ + and `the documentation for flask.Flask.errorhandler `_ for more + information. + + .. versionadded:: 1.3.0 + """ + from collections import defaultdict + + def decorator(f): + if ( + not hasattr(f, "_blueprint_error_handler") + or f._blueprint_error_handler is None + ): + f._blueprint_error_handler = defaultdict(list) + f._blueprint_error_handler[f.__name__].append(code_or_exception) + return f + + return decorator + + # noinspection PyProtectedMember + def get_blueprint(self): + """ + Creates and returns the blueprint for your plugin. Override this if you want to define and handle your blueprint yourself. + + This method will only be called once during server initialization. + + :return: the blueprint ready to be registered with Flask + """ + + if hasattr(self, "_blueprint"): + # if we already constructed the blueprint and hence have it cached, + # return that instance - we don't want to instance it multiple times + return self._blueprint + + import flask + + kwargs = self.get_blueprint_kwargs() + blueprint = flask.Blueprint( + "plugin_" + self._identifier.replace(".", "_") + "_logic", # Use underscores + self.__module__, # Use module path for better resolution + **kwargs + ) + + # we now iterate over all members of ourselves and look if we find an attribute + # that has data originating from one of our decorators - we ignore anything + # starting with a _ to only handle public stuff + for member in [member for member in dir(self) if not member.startswith("_")]: + f = getattr(self, member) + + if hasattr(f, "_blueprint_rules") and member in f._blueprint_rules: + # this attribute was annotated with our @route decorator + for blueprint_rule in f._blueprint_rules[member]: + rule, options = blueprint_rule + blueprint.add_url_rule( + rule, options.pop("endpoint", f.__name__), view_func=f, **options + ) + + if ( + hasattr(f, "_blueprint_error_handler") + and member in f._blueprint_error_handler + ): + # this attribute was annotated with our @error_handler decorator + for code_or_exception in f._blueprint_error_handler[member]: + blueprint.errorhandler(code_or_exception)(f) + + # cache and return the blueprint object + self._blueprint = blueprint + return blueprint + + def get_blueprint_kwargs(self): + """ + Override this if you want your blueprint constructed with additional options such as ``static_folder``, + ``template_folder``, etc. + + Defaults to the blueprint's ``static_folder`` and ``template_folder`` to be set to the plugin's basefolder + plus ``/static`` or respectively ``/templates``, or -- if the plugin also implements :class:`AssetPlugin` and/or + :class:`TemplatePlugin` -- the paths provided by ``get_asset_folder`` and ``get_template_folder`` respectively. + """ + import os + + if isinstance(self, AssetPlugin): + static_folder = self.get_asset_folder() + else: + static_folder = os.path.join(self._basefolder, "static") + + if isinstance(self, TemplatePlugin): + template_folder = self.get_template_folder() + else: + template_folder = os.path.join(self._basefolder, "templates") + + return {"static_folder": static_folder, "template_folder": template_folder} + + # noinspection PyMethodMayBeStatic + def is_blueprint_protected(self): + """ + Whether a login session by a registered user is needed to access the blueprint's endpoints. Requiring + a session is the default. Note that this only restricts access to the blueprint's dynamic methods, static files + are always accessible. + + If you want your blueprint's endpoints to have specific permissions, return ``False`` for this and do your + permissions checks explicitly. + """ + return True + + # noinspection PyMethodMayBeStatic + def get_blueprint_api_prefixes(self): + """ + Return all prefixes of your endpoint that are an API that should be containing JSON only. + + Anything that matches this will generate JSON error messages in case of flask.abort + calls, instead of the default HTML ones. + + Defaults to all endpoints under the blueprint. Limit this further as needed. E.g., + if you only want your endpoints /foo, /foo/1 and /bar to be declared as API, + return ``["/foo", "/bar"]``. A match will be determined via startswith. + """ + return [""] - import octoprint.plugin - class MySettingsPlugin(octoprint.plugin.SettingsPlugin, octoprint.plugin.StartupPlugin): - def get_settings_defaults(self): - return dict( - some_setting="foo", - some_value=23, - sub=dict( - some_flag=True - ) - ) - - def on_settings_save(self, data): - old_flag = self._settings.get_boolean(["sub", "some_flag"]) - - octoprint.plugin.SettingsPlugin.on_settings_save(self, data) - - new_flag = self._settings.get_boolean(["sub", "some_flag"]) - if old_flag != new_flag: - self._logger.info("sub.some_flag changed from {old_flag} to {new_flag}".format(**locals())) - - def on_after_startup(self): - some_setting = self._settings.get(["some_setting"]) - some_value = self._settings.get_int(["some_value"]) - some_flag = self._settings.get_boolean(["sub", "some_flag"]) - self._logger.info("some_setting = {some_setting}, some_value = {some_value}, sub.some_flag = {some_flag}".format(**locals()) - - __plugin_implementation__ = MySettingsPlugin() - - Of course, you are always free to completely override both :func:`on_settings_load` and :func:`on_settings_save` if the - default implementations do not fit your requirements. - - - .. warning:: - - Make sure to protect sensitive information stored by your plugin that only logged in administrators (or users) - should have access to via :meth:`~octoprint.plugin.SettingsPlugin.get_settings_restricted_paths`. OctoPrint will - return its settings on the REST API even to anonymous clients, but will filter out fields it know are restricted, - therefore you **must** make sure that you specify sensitive information accordingly to limit access as required! - """ - - config_version_key = "_config_version" - """Key of the field in the settings that holds the configuration format version.""" - - # noinspection PyMissingConstructor - def __init__(self): - self._settings = None - """ - The :class:`~octoprint.plugin.PluginSettings` instance to use for accessing the plugin's settings. Injected by - the plugin core system upon initialization of the implementation. - """ - - def on_settings_load(self): - """ - Loads the settings for the plugin, called by the Settings API view in order to retrieve all settings from - all plugins. Override this if you want to inject additional settings properties that are not stored within - OctoPrint's configuration. - - .. note:: - - The default implementation will return your plugin's settings as is, so just in the structure and in the types - that are currently stored in OctoPrint's configuration. - - If you need more granular control here, e.g. over the used data types, you'll need to override this method - and iterate yourself over all your settings, using the proper retriever methods on the settings manager - to retrieve the data in the correct format. - - The default implementation will also replace any paths that have been restricted by your plugin through - :func:`~octoprint.plugin.SettingsPlugin.get_settings_restricted_paths` with either the provided - default value (if one was provided), an empty dictionary (as fallback for restricted dictionaries), an - empty list (as fallback for restricted lists) or ``None`` values where necessary. - Make sure to do your own restriction if you decide to fully overload this method. - - :return: the current settings of the plugin, as a dictionary - """ - from flask.ext.login import current_user - import copy - - data = copy.deepcopy(self._settings.get_all_data(merged=True)) - if self.config_version_key in data: - del data[self.config_version_key] - - restricted_paths = self.get_settings_restricted_paths() - - # noinspection PyShadowingNames - def restrict_path_unless(data, path, condition): - if not path: - return - - if condition(): - return - - node = data - - if len(path) > 1: - for entry in path[:-1]: - if not entry in node: - return - node = node[entry] - - key = path[-1] - default_value_available = False - default_value = None - if isinstance(key, (list, tuple)): - # key, default_value tuple - key, default_value = key - default_value_available = True - - if key in node: - if default_value_available: - if callable(default_value): - default_value = default_value() - node[key] = default_value - else: - if isinstance(node[key], dict): - node[key] = dict() - elif isinstance(node[key], (list, tuple)): - node[key] = [] - else: - node[key] = None - - conditions = dict(user=lambda: current_user is not None and not current_user.is_anonymous(), - admin=lambda: current_user is not None and not current_user.is_anonymous() and current_user.is_admin(), - never=lambda: False) - - for level, condition in conditions.items(): - paths_for_level = restricted_paths.get(level, []) - for path in paths_for_level: - restrict_path_unless(data, path, condition) - - return data - - def on_settings_save(self, data): - """ - Saves the settings for the plugin, called by the Settings API view in order to persist all settings - from all plugins. Override this if you need to directly react to settings changes or want to extract - additional settings properties that are not stored within OctoPrint's configuration. - - .. note:: - - The default implementation will persist your plugin's settings as is, so just in the structure and in the - types that were received by the Settings API view. Values identical to the default settings values - will *not* be persisted. - - If you need more granular control here, e.g. over the used data types, you'll need to override this method - and iterate yourself over all your settings, retrieving them (if set) from the supplied received ``data`` - and using the proper setter methods on the settings manager to persist the data in the correct format. - - Arguments: - data (dict): The settings dictionary to be saved for the plugin - - Returns: - dict: The settings that differed from the defaults and were actually saved. - """ - import octoprint.util - - # get the current data - current = self._settings.get_all_data() - if current is None: - current = dict() - - # merge our new data on top of it - new_current = octoprint.util.dict_merge(current, data) - if self.config_version_key in new_current: - del new_current[self.config_version_key] - - # determine diff dict that contains minimal set of changes against the - # default settings - we only want to persist that, not everything - diff = octoprint.util.dict_minimal_mergediff(self.get_settings_defaults(), new_current) - - version = self.get_settings_version() - - to_persist = dict(diff) - if version: - to_persist[self.config_version_key] = version - - if to_persist: - self._settings.set([], to_persist) - else: - self._settings.clean_all_data() - - return diff - - # noinspection PyMethodMayBeStatic - def get_settings_defaults(self): - """ - Retrieves the plugin's default settings with which the plugin's settings manager will be initialized. - - Override this in your plugin's implementation and return a dictionary defining your settings data structure - with included default values. - """ - return dict() - - # noinspection PyMethodMayBeStatic - def get_settings_restricted_paths(self): - """ - Retrieves the list of paths in the plugin's settings which be restricted on the REST API. - - Override this in your plugin's implementation to restrict whether a path should only be returned to logged in - users & admins, only to admins, or never on the REST API. - - Return a ``dict`` with the keys ``admin``, ``user``, ``never`` mapping to a list of paths (as tuples or lists of - the path elements) for which to restrict access via the REST API accordingly. Paths returned for the ``admin`` - key will only be available on the REST API when access with admin rights, ``user`` will only be available when accessed - as a logged in user. ``never`` will never be returned on the API. - - Example: - - .. code-block:: python - - def get_settings_defaults(self): - return dict(some=dict(admin_only=dict(path="path", foo="foo"), - user_only=dict(path="path", bar="bar")), - another=dict(admin_only=dict(path="path"), - field="field"), - path=dict(to=dict(never=dict(return="return")))) - - def get_settings_restricted_paths(self): - return dict(admin=[["some", "admin_only", "path"], ["another", "admin_only", "path"],], - user=[["some", "user_only", "path"],], - never=[["path", "to", "never", "return"],]) - - # this will make the plugin return settings on the REST API like this for an anonymous user - # - # dict(some=dict(admin_only=dict(path=None, foo="foo"), - # user_only=dict(path=None, bar="bar")), - # another=dict(admin_only=dict(path=None), - # field="field"), - # path=dict(to=dict(never=dict(return=None)))) - # - # like this for a logged in user - # - # dict(some=dict(admin_only=dict(path=None, foo="foo"), - # user_only=dict(path="path", bar="bar")), - # another=dict(admin_only=dict(path=None), - # field="field"), - # path=dict(to=dict(never=dict(return=None)))) - # - # and like this for an admin user - # - # dict(some=dict(admin_only=dict(path="path", foo="foo"), - # user_only=dict(path="path", bar="bar")), - # another=dict(admin_only=dict(path="path"), - # field="field"), - # path=dict(to=dict(never=dict(return=None)))) - """ - return dict() - - # noinspection PyMethodMayBeStatic - def get_settings_preprocessors(self): - """ - Retrieves the plugin's preprocessors to use for preprocessing returned or set values prior to returning/setting - them. - - The preprocessors should be provided as a dictionary mapping the path of the values to preprocess - (hierarchically) to a transform function which will get the value to transform as only input and should return - the transformed value. - - Example: - - .. code-block:: python - - def get_settings_defaults(self): - return dict(some_key="Some_Value", some_other_key="Some_Value") - - def get_settings_preprocessors(self): - return dict(some_key=lambda x: x.upper()), # getter preprocessors - dict(some_other_key=lambda x: x.lower()) # setter preprocessors - - def some_method(self): - # getting the value for "some_key" should turn it to upper case - assert self._settings.get(["some_key"]) == "SOME_VALUE" - - # the value for "some_other_key" should be left alone - assert self._settings.get(["some_other_key"] = "Some_Value" - - # setting a value for "some_other_key" should cause the value to first be turned to lower case - self._settings.set(["some_other_key"], "SOME_OTHER_VALUE") - assert self._settings.get(["some_other_key"]) == "some_other_value" - - Returns: - (dict, dict): A tuple consisting of two dictionaries, the first being the plugin's preprocessors for - getters, the second the preprocessors for setters - """ - return dict(), dict() - - # noinspection PyMethodMayBeStatic - def get_settings_version(self): - """ - Retrieves the settings format version of the plugin. - - Use this to have OctoPrint trigger your migration function if it detects an outdated settings version in - config.yaml. - - Returns: - int or None: an int signifying the current settings format, should be incremented by plugins whenever there - are backwards incompatible changes. Returning None here disables the version tracking for the - plugin's configuration. - """ - return None - - # noinspection PyMethodMayBeStatic - def on_settings_migrate(self, target, current): - """ - Called by OctoPrint if it detects that the installed version of the plugin necessitates a higher settings version - than the one currently stored in _config.yaml. Will also be called if the settings data stored in config.yaml - doesn't have version information, in which case the ``current`` parameter will be None. - - Your plugin's implementation should take care of migrating any data by utilizing self._settings. OctoPrint - will take care of saving any changes to disk by calling `self._settings.save()` after returning from this method. - - This method will be called before your plugin's :func:`on_settings_initialized` method, with all injections already - having taken place. You can therefore depend on the configuration having been migrated by the time - :func:`on_settings_initialized` is called. - - Arguments: - target (int): The settings format version the plugin requires, this should always be the same value as - returned by :func:`get_settings_version`. - current (int or None): The settings format version as currently stored in config.yaml. May be None if - no version information can be found. - """ - pass - - def on_settings_cleanup(self): - """ - Called after migration and initialization but before call to :func:`on_settings_initialized`. - - Plugins may overwrite this method to perform additional clean up tasks. - - The default implementation just minimizes the data persisted on disk to only contain - the differences to the defaults (in case the current data was persisted with an older - version of OctoPrint that still duplicated default data). - """ - import octoprint.util - from octoprint.settings import NoSuchSettingsPath - - try: - # let's fetch the current persisted config (so only the data on disk, - # without the defaults) - config = self._settings.get_all_data(merged=False, incl_defaults=False, error_on_path=True) - except NoSuchSettingsPath: - # no config persisted, nothing to do => get out of here - return - - if config is None: - # config is set to None, that doesn't make sense, kill it and leave - self._settings.clean_all_data() - return - - if self.config_version_key in config and config[self.config_version_key] is None: - # delete None entries for config version - it's the default, no need - del config[self.config_version_key] - - # calculate a minimal diff between the settings and the current config - - # anything already in the settings will be removed from the persisted - # config, no need to duplicate it - defaults = self.get_settings_defaults() - diff = octoprint.util.dict_minimal_mergediff(defaults, config) - - if not diff: - # no diff to defaults, no need to have anything persisted - self._settings.clean_all_data() - else: - # diff => persist only that - self._settings.set([], diff) - - def on_settings_initialized(self): - """ - Called after the settings have been initialized and - if necessary - also been migrated through a call to - func:`on_settings_migrate`. - - This method will always be called after the `initialize` method. - """ - pass +class SettingsPlugin(OctoPrintPlugin): + """ + Including the ``SettingsPlugin`` mixin allows plugins to store and retrieve their own settings within OctoPrint's + configuration. + Plugins including the mixing will get injected an additional property ``self._settings`` which is an instance of + :class:`PluginSettingsManager` already properly initialized for use by the plugin. In order for the manager to + know about the available settings structure and default values upon initialization, implementing plugins will need + to provide a dictionary with the plugin's default settings through overriding the method :func:`get_settings_defaults`. + The defined structure will then be available to access through the settings manager available as ``self._settings``. -class EventHandlerPlugin(OctoPrintPlugin): - """ - The ``EventHandlerPlugin`` mixin allows OctoPrint plugins to react to any of :ref:`OctoPrint's events `. - OctoPrint will call the :func:`on_event` method for any event fired on its internal event bus, supplying the - event type and the associated payload. Please note that until your plugin returns from that method, further event - processing within OctoPrint will block - the event queue itself is run asynchronously from the rest of OctoPrint, - but the processing of the events within the queue itself happens consecutively. - - This mixin is especially interesting for plugins which want to react on things like print jobs finishing, timelapse - videos rendering etc. - """ - - # noinspection PyMethodMayBeStatic - def on_event(self, event, payload): - """ - Called by OctoPrint upon processing of a fired event on the platform. - - Arguments: - event (str): The type of event that got fired, see :ref:`the list of events ` - for possible values - payload (dict): The payload as provided with the event - """ - pass + If your plugin needs to react to the change of specific configuration values on the fly, e.g. to adjust the log level + of a logger when the user changes a corresponding flag via the settings dialog, you can override the + :func:`on_settings_save` method and wrap the call to the implementation from the parent class with retrieval of the + old and the new value and react accordingly. + Example: -class SlicerPlugin(OctoPrintPlugin): - """ - Via the ``SlicerPlugin`` mixin plugins can add support for slicing engines to be used by OctoPrint. - - """ - - # noinspection PyMethodMayBeStatic - def is_slicer_configured(self): - """ - Unless the return value of this method is ``True``, OctoPrint will not register the slicer within the slicing - sub system upon startup. Plugins may use this to do some start up checks to verify that e.g. the path to - a slicing binary as set and the binary is executable, or credentials of a cloud slicing platform are properly - entered etc. - """ - return False - - # noinspection PyMethodMayBeStatic - def get_slicer_properties(self): - """ - Plugins should override this method to return a ``dict`` containing a bunch of meta data about the implemented slicer. - - The expected keys in the returned ``dict`` have the following meaning: - - type - The type identifier to use for the slicer. This should be a short unique lower case string which will be - used to store slicer profiles under or refer to the slicer programmatically or from the API. - name - The human readable name of the slicer. This will be displayed to the user during slicer selection. - same_device - True if the slicer runs on the same device as OctoPrint, False otherwise. Slicers running on the same - device will not be allowed to slice while a print is running due to performance reasons. Slice requests - against slicers running on the same device will result in an error. - progress_report - ``True`` if the slicer can report back slicing progress to OctoPrint ``False`` otherwise. - source_file_types - A list of file types this slicer supports as valid origin file types. These are file types as found in the - paths within the extension tree. Plugins may add additional file types through the :ref:`sec-plugins-hook-filemanager-extensiontree` hook. - The system will test source files contains in incoming slicing requests via :meth:`octoprint.filemanager.valid_file_type` against the - targeted slicer's ``source_file_types``. - destination_extension - The possible extensions of slicing result files. - - Returns: - dict: A dict describing the slicer as outlined above. - """ - return dict( - type=None, - name=None, - same_device=True, - progress_report=False, - source_file_types=["model"], - destination_extensions=["gco", "gcode", "g"] - ) - - # noinspection PyMethodMayBeStatic - def get_slicer_default_profile(self): - """ - Should return a :class:`~octoprint.slicing.SlicingProfile` containing the default slicing profile to use with - this slicer if no other profile has been selected. - - Returns: - SlicingProfile: The :class:`~octoprint.slicing.SlicingProfile` containing the default slicing profile for - this slicer. - """ - return None - - # noinspection PyMethodMayBeStatic - def get_slicer_profile(self, path): - """ - Should return a :class:`~octoprint.slicing.SlicingProfile` parsed from the slicing profile stored at the - indicated ``path``. - - Arguments: - path (str): The absolute path from which to read the slicing profile. - - Returns: - SlicingProfile: The specified slicing profile. - """ - return None - - # noinspection PyMethodMayBeStatic - def save_slicer_profile(self, path, profile, allow_overwrite=True, overrides=None): - """ - Should save the provided :class:`~octoprint.slicing.SlicingProfile` to the indicated ``path``, after applying - any supplied ``overrides``. If a profile is already saved under the indicated path and ``allow_overwrite`` is - set to False (defaults to True), an :class:`IOError` should be raised. - - Arguments: - path (str): The absolute path to which to save the profile. - profile (SlicingProfile): The profile to save. - allow_overwrite (boolean): Whether to allow to overwrite an existing profile at the indicated path (True, - default) or not (False). If a profile already exists on the path and this is False an - :class:`IOError` should be raised. - overrides (dict): Profile overrides to apply to the ``profile`` before saving it - """ - pass - - # noinspection PyMethodMayBeStatic - def do_slice(self, model_path, printer_profile, machinecode_path=None, profile_path=None, position=None, on_progress=None, on_progress_args=None, on_progress_kwargs=None): - """ - Called by OctoPrint to slice ``model_path`` for the indicated ``printer_profile``. If the ``machinecode_path`` is ``None``, - slicer implementations should generate it from the provided ``model_path``. - - If provided, the ``profile_path`` is guaranteed by OctoPrint to be a serialized slicing profile created through the slicing - plugin's own :func:`save_slicer_profile` method. - - If provided, ``position`` will be a ``dict`` containing and ``x`` and a ``y`` key, indicating the position - the center of the model on the print bed should have in the final sliced machine code. If not provided, slicer - implementations should place the model in the center of the print bed. - - ``on_progress`` will be a callback which expects an additional keyword argument ``_progress`` with the current - slicing progress which - if progress reporting is supported - the slicing plugin should call like the following: - - .. code-block:: python - - if on_progress is not None: - if on_progress_args is None: - on_progress_args = () - if on_progress_kwargs is None: - on_progress_kwargs = dict() - - on_progress_kwargs["_progress"] = your_plugins_slicing_progress - on_progress(*on_progress_args, **on_progress_kwargs) - - Please note that both ``on_progress_args`` and ``on_progress_kwargs`` as supplied by OctoPrint might be ``None``, - so always make sure to initialize those values to sane defaults like depicted above before invoking the callback. - - In order to support external cancellation of an ongoing slicing job via :func:`cancel_slicing`, implementations - should make sure to track the started jobs via the ``machinecode_path``, if provided. - - The method should return a 2-tuple consisting of a boolean ``flag`` indicating whether the slicing job was - finished successfully (True) or not (False) and a ``result`` depending on the success of the slicing job. - - For jobs that finished successfully, ``result`` should be a :class:`dict` containing additional information - about the slicing job under the following keys: - - _analysis - Analysis result of the generated machine code as returned by the slicer itself. This should match the - data structure described for the analysis queue of the matching machine code format, e.g. - :class:`~octoprint.filemanager.analysis.GcodeAnalysisQueue` for GCODE files. - - For jobs that did not finish successfully (but not due to being cancelled!), ``result`` should be a :class:`str` - containing a human readable reason for the error. - - If the job gets cancelled, a :class:`~octoprint.slicing.SlicingCancelled` exception should be raised. - - Returns: - tuple: A 2-tuple (boolean, object) as outlined above. - - Raises: - SlicingCancelled: The slicing job was cancelled (via :meth:`cancel_slicing`). - """ - pass - - # noinspection PyMethodMayBeStatic - def cancel_slicing(self, machinecode_path): - """ - Cancels the slicing to the indicated file. + .. code-block:: python - Arguments: - machinecode_path (str): The absolute path to the machine code file to which to stop slicing to. - """ - pass + import octoprint.plugin + class MySettingsPlugin(octoprint.plugin.SettingsPlugin, octoprint.plugin.StartupPlugin): + def get_settings_defaults(self): + return dict( + some_setting="foo", + some_value=23, + sub=dict( + some_flag=True + ) + ) -class ProgressPlugin(OctoPrintPlugin): - """ - Via the ``ProgressPlugin`` mixing plugins can let themselves be called upon progress in print jobs or slicing jobs, - limited to minimally 1% steps. - """ + def on_settings_save(self, data): + old_flag = self._settings.get_boolean(["sub", "some_flag"]) - # noinspection PyMethodMayBeStatic - def on_print_progress(self, storage, path, progress): - """ - Called by OctoPrint on minimally 1% increments during a running print job. + octoprint.plugin.SettingsPlugin.on_settings_save(self, data) + + new_flag = self._settings.get_boolean(["sub", "some_flag"]) + if old_flag != new_flag: + self._logger.info("sub.some_flag changed from {old_flag} to {new_flag}".format(**locals())) + + def on_after_startup(self): + some_setting = self._settings.get(["some_setting"]) + some_value = self._settings.get_int(["some_value"]) + some_flag = self._settings.get_boolean(["sub", "some_flag"]) + self._logger.info("some_setting = {some_setting}, some_value = {some_value}, sub.some_flag = {some_flag}".format(**locals()) + + __plugin_implementation__ = MySettingsPlugin() + + Of course, you are always free to completely override both :func:`on_settings_load` and :func:`on_settings_save` if the + default implementations do not fit your requirements. + + + .. warning:: + + Make sure to protect sensitive information stored by your plugin that only logged in administrators (or users) + should have access to via :meth:`~octoprint.plugin.SettingsPlugin.get_settings_restricted_paths`. OctoPrint will + return its settings on the REST API even to anonymous clients, but will filter out fields it knows are restricted, + therefore you **must** make sure that you specify sensitive information accordingly to limit access as required! + """ + + config_version_key = "_config_version" + """Key of the field in the settings that holds the configuration format version.""" + + # noinspection PyMissingConstructor + def __init__(self): + self._settings = None + """ + The :class:`~octoprint.plugin.PluginSettings` instance to use for accessing the plugin's settings. Injected by + the plugin core system upon initialization of the implementation. + """ + + def on_settings_load(self): + """ + Loads the settings for the plugin, called by the Settings API view in order to retrieve all settings from + all plugins. Override this if you want to inject additional settings properties that are not stored within + OctoPrint's configuration. + + .. note:: + + The default implementation will return your plugin's settings as is, so just in the structure and in the types + that are currently stored in OctoPrint's configuration. + + If you need more granular control here, e.g. over the used data types, you'll need to override this method + and iterate yourself over all your settings, using the proper retriever methods on the settings manager + to retrieve the data in the correct format. + + The default implementation will also replace any paths that have been restricted by your plugin through + :func:`~octoprint.plugin.SettingsPlugin.get_settings_restricted_paths` with either the provided + default value (if one was provided), an empty dictionary (as fallback for restricted dictionaries), an + empty list (as fallback for restricted lists) or ``None`` values where necessary. + Make sure to do your own restriction if you decide to fully overload this method. + + :return: the current settings of the plugin, as a dictionary + """ + import copy + + from flask_login import current_user + + from octoprint.access.permissions import OctoPrintPermission, Permissions + + data = copy.deepcopy(self._settings.get_all_data(merged=True)) + if self.config_version_key in data: + del data[self.config_version_key] + + restricted_paths = self.get_settings_restricted_paths() + + # noinspection PyShadowingNames + def restrict_path_unless(data, path, condition): + if not path: + return + + if condition(): + return + + node = data + + if len(path) > 1: + for entry in path[:-1]: + if entry not in node: + return + node = node[entry] + + key = path[-1] + default_value_available = False + default_value = None + if isinstance(key, (list, tuple)): + # key, default_value tuple + key, default_value = key + default_value_available = True + + if key in node: + if default_value_available: + if callable(default_value): + default_value = default_value() + node[key] = default_value + else: + if isinstance(node[key], dict): + node[key] = {} + elif isinstance(node[key], (list, tuple)): + node[key] = [] + else: + node[key] = None + + conditions = { + "user": lambda: current_user is not None and not current_user.is_anonymous, + "admin": lambda: current_user is not None + and current_user.has_permission(Permissions.SETTINGS), + "never": lambda: False, + } + + for level, paths in restricted_paths.items(): + if isinstance(level, OctoPrintPermission): + condition = lambda: ( + current_user is not None and current_user.has_permission(level) + ) + else: + condition = conditions.get(level, lambda: False) + + for path in paths: + restrict_path_unless(data, path, condition) + + return data + + def on_settings_save(self, data): + """ + Saves the settings for the plugin, called by the Settings API view in order to persist all settings + from all plugins. Override this if you need to directly react to settings changes or want to extract + additional settings properties that are not stored within OctoPrint's configuration. + + .. note:: + + The default implementation will persist your plugin's settings as is, so just in the structure and in the + types that were received by the Settings API view. Values identical to the default settings values + will *not* be persisted. + + If you need more granular control here, e.g. over the used data types, you'll need to override this method + and iterate yourself over all your settings, retrieving them (if set) from the supplied received ``data`` + and using the proper setter methods on the settings manager to persist the data in the correct format. + + Arguments: + data (dict): The settings dictionary to be saved for the plugin + + Returns: + dict: The settings that differed from the defaults and were actually saved. + """ + import octoprint.util + + # get the current data + current = self._settings.get_all_data() + if current is None: + current = {} + + # merge our new data on top of it + new_current = octoprint.util.dict_merge(current, data) + if self.config_version_key in new_current: + del new_current[self.config_version_key] + + # determine diff dict that contains minimal set of changes against the + # default settings - we only want to persist that, not everything + diff = octoprint.util.dict_minimal_mergediff( + self.get_settings_defaults(), new_current + ) + + version = self.get_settings_version() + + to_persist = dict(diff) + if version: + to_persist[self.config_version_key] = version + + if to_persist: + self._settings.set([], to_persist) + else: + self._settings.clean_all_data() + + return diff + + # noinspection PyMethodMayBeStatic + def get_settings_defaults(self): + """ + Retrieves the plugin's default settings with which the plugin's settings manager will be initialized. + + Override this in your plugin's implementation and return a dictionary defining your settings data structure + with included default values. + """ + return {} + + # noinspection PyMethodMayBeStatic + def get_settings_restricted_paths(self): + """ + Retrieves the list of paths in the plugin's settings which be restricted on the REST API. + + Override this in your plugin's implementation to restrict whether a path should only be returned to users with + certain permissions, or never on the REST API. + + Return a ``dict`` with one of the following keys, mapping to a list of paths (as tuples or lists of + the path elements) for which to restrict access via the REST API accordingly. + + * An :py:class:`~octoprint.access.permissions.OctoPrintPermission` instance: Paths will only be available on the REST API for users with the permission + * ``admin``: Paths will only be available on the REST API for users with admin rights (any user with the SETTINGS permission) + * ``user``: Paths will only be available on the REST API when accessed as a logged in user + * ``never``: Paths will never be returned on the API + + Example: + + .. code-block:: python + + def get_settings_defaults(self): + return dict(some=dict(admin_only=dict(path="path", foo="foo"), + user_only=dict(path="path", bar="bar")), + another=dict(admin_only=dict(path="path"), + field="field"), + path=dict(to=dict(never=dict(return="return"))), + the=dict(webcam=dict(data="webcam"))) + + def get_settings_restricted_paths(self): + from octoprint.access.permissions import Permissions + return {'admin':[["some", "admin_only", "path"], ["another", "admin_only", "path"],], + 'user':[["some", "user_only", "path"],], + 'never':[["path", "to", "never", "return"],], + Permissions.WEBCAM:[["the", "webcam", "data"],]} + + # this will make the plugin return settings on the REST API like this for an anonymous user + # + # dict(some=dict(admin_only=dict(path=None, foo="foo"), + # user_only=dict(path=None, bar="bar")), + # another=dict(admin_only=dict(path=None), + # field="field"), + # path=dict(to=dict(never=dict(return=None))), + # the=dict(webcam=dict(data=None))) + # + # like this for a logged in user without the webcam permission + # + # dict(some=dict(admin_only=dict(path=None, foo="foo"), + # user_only=dict(path="path", bar="bar")), + # another=dict(admin_only=dict(path=None), + # field="field"), + # path=dict(to=dict(never=dict(return=None))), + # the=dict(webcam=dict(data=None))) + # + # like this for a logged in user with the webcam permission + # + # dict(some=dict(admin_only=dict(path=None, foo="foo"), + # user_only=dict(path="path", bar="bar")), + # another=dict(admin_only=dict(path=None), + # field="field"), + # path=dict(to=dict(never=dict(return=None))), + # the=dict(webcam=dict(data="webcam"))) + # + # and like this for an admin user + # + # dict(some=dict(admin_only=dict(path="path", foo="foo"), + # user_only=dict(path="path", bar="bar")), + # another=dict(admin_only=dict(path="path"), + # field="field"), + # path=dict(to=dict(never=dict(return=None))), + # the=dict(webcam=dict(data="webcam"))) + + .. versionadded:: 1.2.17 + """ + return {} + + # noinspection PyMethodMayBeStatic + def get_settings_preprocessors(self): + """ + Retrieves the plugin's preprocessors to use for preprocessing returned or set values prior to returning/setting + them. + + The preprocessors should be provided as a dictionary mapping the path of the values to preprocess + (hierarchically) to a transform function which will get the value to transform as only input and should return + the transformed value. + + Example: + + .. code-block:: python + + def get_settings_defaults(self): + return dict(some_key="Some_Value", some_other_key="Some_Value") + + def get_settings_preprocessors(self): + return dict(some_key=lambda x: x.upper()), # getter preprocessors + dict(some_other_key=lambda x: x.lower()) # setter preprocessors + + def some_method(self): + # getting the value for "some_key" should turn it to upper case + assert self._settings.get(["some_key"]) == "SOME_VALUE" + + # the value for "some_other_key" should be left alone + assert self._settings.get(["some_other_key"] = "Some_Value" + + # setting a value for "some_other_key" should cause the value to first be turned to lower case + self._settings.set(["some_other_key"], "SOME_OTHER_VALUE") + assert self._settings.get(["some_other_key"]) == "some_other_value" + + Returns: + (dict, dict): A tuple consisting of two dictionaries, the first being the plugin's preprocessors for + getters, the second the preprocessors for setters + """ + return {}, {} + + # noinspection PyMethodMayBeStatic + def get_settings_version(self): + """ + Retrieves the settings format version of the plugin. + + Use this to have OctoPrint trigger your migration function if it detects an outdated settings version in + config.yaml. + + Returns: + int or None: an int signifying the current settings format, should be incremented by plugins whenever there + are backwards incompatible changes. Returning None here disables the version tracking for the + plugin's configuration. + """ + return None + + # noinspection PyMethodMayBeStatic + def on_settings_migrate(self, target, current): + """ + Called by OctoPrint if it detects that the installed version of the plugin necessitates a higher settings version + than the one currently stored in _config.yaml. Will also be called if the settings data stored in config.yaml + doesn't have version information, in which case the ``current`` parameter will be None. + + Your plugin's implementation should take care of migrating any data by utilizing self._settings. OctoPrint + will take care of saving any changes to disk by calling `self._settings.save()` after returning from this method. + + This method will be called before your plugin's :func:`on_settings_initialized` method, with all injections already + having taken place. You can therefore depend on the configuration having been migrated by the time + :func:`on_settings_initialized` is called. + + Arguments: + target (int): The settings format version the plugin requires, this should always be the same value as + returned by :func:`get_settings_version`. + current (int or None): The settings format version as currently stored in config.yaml. May be None if + no version information can be found. + """ + pass + + def on_settings_cleanup(self): + """ + Called after migration and initialization but before call to :func:`on_settings_initialized`. + + Plugins may overwrite this method to perform additional clean up tasks. + + The default implementation just minimizes the data persisted on disk to only contain + the differences to the defaults (in case the current data was persisted with an older + version of OctoPrint that still duplicated default data). + + .. versionadded:: 1.3.0 + """ + import octoprint.util + from octoprint.settings import NoSuchSettingsPath + + try: + # let's fetch the current persisted config (so only the data on disk, + # without the defaults) + config = self._settings.get_all_data( + merged=False, incl_defaults=False, error_on_path=True + ) + except NoSuchSettingsPath: + # no config persisted, nothing to do => get out of here + return + + if config is None: + # config is set to None, that doesn't make sense, kill it and leave + self._settings.clean_all_data() + return + + if self.config_version_key in config and config[self.config_version_key] is None: + # delete None entries for config version - it's the default, no need + del config[self.config_version_key] + + # calculate a minimal diff between the settings and the current config - + # anything already in the settings will be removed from the persisted + # config, no need to duplicate it + defaults = self.get_settings_defaults() + diff = octoprint.util.dict_minimal_mergediff(defaults, config) + + if not diff: + # no diff to defaults, no need to have anything persisted + self._settings.clean_all_data() + else: + # diff => persist only that + self._settings.set([], diff) + + def on_settings_initialized(self): + """ + Called after the settings have been initialized and - if necessary - also been migrated through a call to + func:`on_settings_migrate`. + + This method will always be called after the `initialize` method. + """ + pass - :param string storage: Location of the file - :param string path: Path of the file - :param int progress: Current progress as a value between 0 and 100 - """ - pass - # noinspection PyMethodMayBeStatic - def on_slicing_progress(self, slicer, source_location, source_path, destination_location, destination_path, progress): - """ - Called by OctoPrint on minimally 1% increments during a running slicing job. +class EventHandlerPlugin(OctoPrintPlugin): + """ + The ``EventHandlerPlugin`` mixin allows OctoPrint plugins to react to any of :ref:`OctoPrint's events `. + OctoPrint will call the :func:`on_event` method for any event fired on its internal event bus, supplying the + event type and the associated payload. Please note that until your plugin returns from that method, further event + processing within OctoPrint will block - the event queue itself is run asynchronously from the rest of OctoPrint, + but the processing of the events within the queue itself happens consecutively. - :param string slicer: Key of the slicer reporting the progress - :param string source_location: Location of the source file - :param string source_path: Path of the source file - :param string destination_location: Location the destination file - :param string destination_path: Path of the destination file - :param int progress: Current progress as a value between 0 and 100 - """ - pass + This mixin is especially interesting for plugins which want to react on things like print jobs finishing, timelapse + videos rendering etc. + """ + # noinspection PyMethodMayBeStatic + def on_event(self, event, payload): + """ + Called by OctoPrint upon processing of a fired event on the platform. -class AppPlugin(OctoPrintPlugin): - """ - Using the :class:`AppPlugin mixin` plugins may register additional :ref:`App session key providers ` - within the system. + .. warning:: - .. deprecated:: 1.2.0 + Do not perform long-running or even blocking operations in your implementation or you **will** block and break the server. - Refer to the :ref:`octoprint.accesscontrol.appkey hook ` instead. + Arguments: + event (str): The type of event that got fired, see :ref:`the list of events ` + for possible values + payload (dict): The payload as provided with the event + """ + pass - """ - # noinspection PyMethodMayBeStatic - def get_additional_apps(self): - return [] +class SlicerPlugin(OctoPrintPlugin): + """ + Via the ``SlicerPlugin`` mixin plugins can add support for slicing engines to be used by OctoPrint. + + """ + + # noinspection PyMethodMayBeStatic + def is_slicer_configured(self): + """ + Unless the return value of this method is ``True``, OctoPrint will not register the slicer within the slicing + sub system upon startup. Plugins may use this to do some start up checks to verify that e.g. the path to + a slicing binary as set and the binary is executable, or credentials of a cloud slicing platform are properly + entered etc. + """ + return False + + # noinspection PyMethodMayBeStatic + def get_slicer_properties(self): + """ + Plugins should override this method to return a ``dict`` containing a bunch of meta data about the implemented slicer. + + The expected keys in the returned ``dict`` have the following meaning: + + type + The type identifier to use for the slicer. This should be a short unique lower case string which will be + used to store slicer profiles under or refer to the slicer programmatically or from the API. + name + The human readable name of the slicer. This will be displayed to the user during slicer selection. + same_device + True if the slicer runs on the same device as OctoPrint, False otherwise. Slicers running on the same + device will not be allowed to slice on systems with less than two CPU cores (or an unknown number) while a + print is running due to performance reasons. Slice requests against slicers running on the same device and + less than two cores will result in an error. + progress_report + ``True`` if the slicer can report back slicing progress to OctoPrint ``False`` otherwise. + source_file_types + A list of file types this slicer supports as valid origin file types. These are file types as found in the + paths within the extension tree. Plugins may add additional file types through the :ref:`sec-plugins-hook-filemanager-extensiontree` hook. + The system will test source files contains in incoming slicing requests via :meth:`octoprint.filemanager.valid_file_type` against the + targeted slicer's ``source_file_types``. + destination_extension + The possible extensions of slicing result files. + + Returns: + dict: A dict describing the slicer as outlined above. + """ + return { + "type": None, + "name": None, + "same_device": True, + "progress_report": False, + "source_file_types": ["model"], + "destination_extensions": ["gco", "gcode", "g"], + } + + # noinspection PyMethodMayBeStatic + def get_slicer_extension_tree(self): + """ + Fetch additional entries to put into the extension tree for accepted files + + By default, a subtree for ``model`` files with ``stl`` extension is returned. Slicers who want to support + additional/other file types will want to override this. + + For the extension tree format, take a look at the docs of the :ref:`octoprint.filemanager.extension_tree hook `. + + Returns: (dict) a dictionary containing a valid extension subtree. + + .. versionadded:: 1.3.11 + """ + from octoprint.filemanager import ContentTypeMapping + + return {"model": {"stl": ContentTypeMapping(["stl"], "application/sla")}} + + def get_slicer_profiles(self, profile_path): + """ + Fetch all :class:`~octoprint.slicing.SlicingProfile` stored for this slicer. + + For compatibility reasons with existing slicing plugins this method defaults to returning profiles parsed from + .profile files in the plugin's ``profile_path``, utilizing the :func:`SlicingPlugin.get_slicer_profile` method + of the plugin implementation. + + Arguments: + profile_path (str): The base folder where OctoPrint stores this slicer plugin's profiles + + .. versionadded:: 1.3.7 + """ + + try: + from os import scandir + except ImportError: + from scandir import scandir + + import octoprint.util + + profiles = {} + for entry in scandir(profile_path): + if not entry.name.endswith(".profile") or octoprint.util.is_hidden_path( + entry.name + ): + # we are only interested in profiles and no hidden files + continue + + profile_name = entry.name[: -len(".profile")] + profiles[profile_name] = self.get_slicer_profile(entry.path) + return profiles + + # noinspection PyMethodMayBeStatic + def get_slicer_profiles_lastmodified(self, profile_path): + """ + .. versionadded:: 1.3.0 + """ + import os + + try: + from os import scandir + except ImportError: + from scandir import scandir + + lms = [os.stat(profile_path).st_mtime] + lms += [ + os.stat(entry.path).st_mtime + for entry in scandir(profile_path) + if entry.name.endswith(".profile") + ] + return max(lms) + + # noinspection PyMethodMayBeStatic + def get_slicer_default_profile(self): + """ + Should return a :class:`~octoprint.slicing.SlicingProfile` containing the default slicing profile to use with + this slicer if no other profile has been selected. + + Returns: + SlicingProfile: The :class:`~octoprint.slicing.SlicingProfile` containing the default slicing profile for + this slicer. + """ + return None + + # noinspection PyMethodMayBeStatic + def get_slicer_profile(self, path): + """ + Should return a :class:`~octoprint.slicing.SlicingProfile` parsed from the slicing profile stored at the + indicated ``path``. + + Arguments: + path (str): The absolute path from which to read the slicing profile. + + Returns: + SlicingProfile: The specified slicing profile. + """ + return None + + # noinspection PyMethodMayBeStatic + def save_slicer_profile(self, path, profile, allow_overwrite=True, overrides=None): + """ + Should save the provided :class:`~octoprint.slicing.SlicingProfile` to the indicated ``path``, after applying + any supplied ``overrides``. If a profile is already saved under the indicated path and ``allow_overwrite`` is + set to False (defaults to True), an :class:`IOError` should be raised. + + Arguments: + path (str): The absolute path to which to save the profile. + profile (SlicingProfile): The profile to save. + allow_overwrite (boolean): Whether to allow to overwrite an existing profile at the indicated path (True, + default) or not (False). If a profile already exists on the path and this is False an + :class:`IOError` should be raised. + overrides (dict): Profile overrides to apply to the ``profile`` before saving it + """ + pass + + # noinspection PyMethodMayBeStatic + def do_slice( + self, + model_path, + printer_profile, + machinecode_path=None, + profile_path=None, + position=None, + on_progress=None, + on_progress_args=None, + on_progress_kwargs=None, + ): + """ + Called by OctoPrint to slice ``model_path`` for the indicated ``printer_profile``. If the ``machinecode_path`` is ``None``, + slicer implementations should generate it from the provided ``model_path``. + + If provided, the ``profile_path`` is guaranteed by OctoPrint to be a serialized slicing profile created through the slicing + plugin's own :func:`save_slicer_profile` method. + + If provided, ``position`` will be a ``dict`` containing and ``x`` and a ``y`` key, indicating the position + the center of the model on the print bed should have in the final sliced machine code. If not provided, slicer + implementations should place the model in the center of the print bed. + + ``on_progress`` will be a callback which expects an additional keyword argument ``_progress`` with the current + slicing progress which - if progress reporting is supported - the slicing plugin should call like the following: + + .. code-block:: python + + if on_progress is not None: + if on_progress_args is None: + on_progress_args = () + if on_progress_kwargs is None: + on_progress_kwargs = dict() + + on_progress_kwargs["_progress"] = your_plugins_slicing_progress + on_progress(*on_progress_args, **on_progress_kwargs) + + Please note that both ``on_progress_args`` and ``on_progress_kwargs`` as supplied by OctoPrint might be ``None``, + so always make sure to initialize those values to sane defaults like depicted above before invoking the callback. + + In order to support external cancellation of an ongoing slicing job via :func:`cancel_slicing`, implementations + should make sure to track the started jobs via the ``machinecode_path``, if provided. + + The method should return a 2-tuple consisting of a boolean ``flag`` indicating whether the slicing job was + finished successfully (True) or not (False) and a ``result`` depending on the success of the slicing job. + + For jobs that finished successfully, ``result`` should be a :class:`dict` containing additional information + about the slicing job under the following keys: + + analysis + Analysis result of the generated machine code as returned by the slicer itself. This should match the + data structure described for the analysis queue of the matching machine code format, e.g. + :class:`~octoprint.filemanager.analysis.GcodeAnalysisQueue` for GCODE files. + + For jobs that did not finish successfully (but not due to being cancelled!), ``result`` should be a :class:`str` + containing a human readable reason for the error. + + If the job gets cancelled, a :class:`~octoprint.slicing.SlicingCancelled` exception should be raised. + + Returns: + tuple: A 2-tuple (boolean, object) as outlined above. + + Raises: + SlicingCancelled: The slicing job was cancelled (via :meth:`cancel_slicing`). + """ + pass + + # noinspection PyMethodMayBeStatic + def cancel_slicing(self, machinecode_path): + """ + Cancels the slicing to the indicated file. + + Arguments: + machinecode_path (str): The absolute path to the machine code file to which to stop slicing to. + """ + pass + +class ProgressPlugin(OctoPrintPlugin): + """ + Via the ``ProgressPlugin`` mixing plugins can let themselves be called upon progress in print jobs or slicing jobs, + limited to minimally 1% steps. + """ + + # noinspection PyMethodMayBeStatic + def on_print_progress(self, storage, path, progress): + """ + Called by OctoPrint on minimally 1% increments during a running print job. + + :param string storage: Location of the file + :param string path: Path of the file + :param int progress: Current progress as a value between 0 and 100 + """ + pass + + # noinspection PyMethodMayBeStatic + def on_slicing_progress( + self, + slicer, + source_location, + source_path, + destination_location, + destination_path, + progress, + ): + """ + Called by OctoPrint on minimally 1% increments during a running slicing job. + + :param string slicer: Key of the slicer reporting the progress + :param string source_location: Location of the source file + :param string source_path: Path of the source file + :param string destination_location: Location the destination file + :param string destination_path: Path of the destination file + :param int progress: Current progress as a value between 0 and 100 + """ + pass diff --git a/src/octoprint/plugins/__init__.py b/src/octoprint/plugins/__init__.py index 1b0510c60c..bb409a2f36 100644 --- a/src/octoprint/plugins/__init__.py +++ b/src/octoprint/plugins/__init__.py @@ -1 +1 @@ -# make our plugins testable +from __future__ import absolute_import, division, print_function, unicode_literals diff --git a/src/octoprint/plugins/action_command_notification/__init__.py b/src/octoprint/plugins/action_command_notification/__init__.py new file mode 100644 index 0000000000..78f65562c5 --- /dev/null +++ b/src/octoprint/plugins/action_command_notification/__init__.py @@ -0,0 +1,151 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2020 The OctoPrint Project - Released under terms of the AGPLv3 License" + +import time + +import flask +from flask_babel import gettext + +import octoprint.plugin +from octoprint.access import USER_GROUP +from octoprint.access.permissions import Permissions +from octoprint.events import Events + + +class ActionCommandNotificationPlugin( + octoprint.plugin.AssetPlugin, + octoprint.plugin.SettingsPlugin, + octoprint.plugin.SimpleApiPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.EventHandlerPlugin, +): + def __init__(self): + self._notifications = [] + + # Additional permissions hook + + def get_additional_permissions(self): + return [ + { + "key": "SHOW", + "name": "Show printer notifications", + "description": gettext("Allows to see printer notifications"), + "default_groups": [USER_GROUP], + "roles": ["show"], + }, + { + "key": "CLEAR", + "name": "Clear printer notifications", + "description": gettext("Allows to clear printer notifications"), + "default_groups": [USER_GROUP], + "roles": ["clear"], + }, + ] + + # ~ AssetPlugin + + def get_assets(self): + return { + "js": ["js/action_command_notification.js"], + "clientjs": ["clientjs/action_command_notification.js"], + "css": ["css/action_command_notification.css"], + } + + # ~ EventHandlerPlugin + + def on_event(self, event, payload): + if event == Events.DISCONNECTED: + self._clear_notifications() + + # ~ SettingsPlugin + + def get_settings_defaults(self): + return {"enable": True, "enable_popups": False} + + # ~ SimpleApiPlugin + + def on_api_get(self, request): + if not Permissions.PLUGIN_ACTION_COMMAND_NOTIFICATION_SHOW.can(): + return flask.abort(403) + return flask.jsonify( + notifications=[ + {"timestamp": notification[0], "message": notification[1]} + for notification in self._notifications + ] + ) + + def get_api_commands(self): + return {"clear": []} + + def on_api_command(self, command, data): + if command == "clear": + if not Permissions.PLUGIN_ACTION_COMMAND_NOTIFICATION_CLEAR.can(): + return flask.abort(403, "Insufficient permissions") + self._clear_notifications() + + # ~ TemplatePlugin + + def get_template_configs(self): + return [ + { + "type": "settings", + "name": gettext("Printer Notifications"), + "custom_bindings": False, + }, + { + "type": "sidebar", + "name": gettext("Printer Notifications"), + "icon": "bell-o", + "styles_wrapper": ["display: none"], + "template_header": "action_command_notification_sidebar_header.jinja2", + "data_bind": "visible: loginState.hasPermissionKo(access.permissions.PLUGIN_ACTION_COMMAND_NOTIFICATION_SHOW)" + " && settings.settings.plugins.action_command_notification.enable()", + }, + ] + + # ~ action command handler + + def action_command_handler(self, comm, line, action, *args, **kwargs): + if not self._settings.get_boolean(["enable"]): + return + + parts = action.split(None, 1) + if len(parts) == 1: + action = parts[0] + parameter = "" + else: + action, parameter = parts + + if action != "notification": + return + + message = parameter.strip() + self._notifications.append((time.time(), message)) + self._plugin_manager.send_plugin_message(self._identifier, {"message": message}) + + self._logger.info("Got a notification: {}".format(message)) + + def _clear_notifications(self): + self._notifications = [] + self._plugin_manager.send_plugin_message(self._identifier, {}) + self._logger.info("Notifications cleared") + + +__plugin_name__ = "Action Command Notification Support" +__plugin_description__ = ( + "Allows your printer to trigger notifications via action commands on the connection" +) +__plugin_author__ = "Gina Häußge" +__plugin_disabling_discouraged__ = gettext( + "Without this plugin your printer will no longer be able to trigger" + " notifications in OctoPrint" +) +__plugin_license__ = "AGPLv3" +__plugin_pythoncompat__ = ">=2.7,<4" +__plugin_implementation__ = ActionCommandNotificationPlugin() +__plugin_hooks__ = { + "octoprint.comm.protocol.action": __plugin_implementation__.action_command_handler, + "octoprint.access.permissions": __plugin_implementation__.get_additional_permissions, +} diff --git a/src/octoprint/plugins/action_command_notification/static/clientjs/action_command_notification.js b/src/octoprint/plugins/action_command_notification/static/clientjs/action_command_notification.js new file mode 100644 index 0000000000..4abaa8957e --- /dev/null +++ b/src/octoprint/plugins/action_command_notification/static/clientjs/action_command_notification.js @@ -0,0 +1,22 @@ +(function (global, factory) { + if (typeof define === "function" && define.amd) { + define(["OctoPrintClient"], factory); + } else { + factory(global.OctoPrintClient); + } +})(this, function (OctoPrintClient) { + var OctoPrintActionCommandNotificationClient = function (base) { + this.base = base; + }; + + OctoPrintActionCommandNotificationClient.prototype.get = function (refresh, opts) { + return this.base.get(this.base.getSimpleApiUrl("action_command_notification"), opts); + }; + + OctoPrintActionCommandNotificationClient.prototype.clear = function (opts) { + return this.base.simpleApiCommand("action_command_notification", "clear", {}, opts); + }; + + OctoPrintClient.registerPluginComponent("action_command_notification", OctoPrintActionCommandNotificationClient); + return OctoPrintActionCommandNotificationClient; +}); diff --git a/src/octoprint/plugins/action_command_notification/static/css/action_command_notification.css b/src/octoprint/plugins/action_command_notification/static/css/action_command_notification.css new file mode 100644 index 0000000000..e15340a1e6 --- /dev/null +++ b/src/octoprint/plugins/action_command_notification/static/css/action_command_notification.css @@ -0,0 +1,11 @@ +.sidebar_plugin_action_command_notification_entry { + padding: 5px; + line-height: 20px; + border-bottom: 1px solid #ddd; +} + +.sidebar_plugin_action_command_notification_scrollable { + overflow-x: hidden; + overflow-y: scroll; + max-height: 306px; +} diff --git a/src/octoprint/plugins/action_command_notification/static/js/action_command_notification.js b/src/octoprint/plugins/action_command_notification/static/js/action_command_notification.js new file mode 100644 index 0000000000..5c1ab7d34b --- /dev/null +++ b/src/octoprint/plugins/action_command_notification/static/js/action_command_notification.js @@ -0,0 +1,91 @@ +$(function () { + function ActionCommandNotificationViewModel(parameters) { + var self = this; + + self.loginState = parameters[0]; + self.access = parameters[1]; + self.settings = parameters[2]; + + self.notifications = ko.observableArray([]); + self.sortDesc = ko.observable(false); + self.sortDesc.subscribe(function () { + self._toLocalStorage(); + }); + + self.toDateTimeString = function (timestamp) { + return formatDate(timestamp); + }; + + self.requestData = function () { + if (!self.loginState.hasPermission(self.access.permissions.PLUGIN_ACTION_COMMAND_NOTIFICATION_SHOW)) return; + + OctoPrint.plugins.action_command_notification.get().done(self.fromResponse); + }; + + self.fromResponse = function (response) { + var notifications = response.notifications; + if (self.sortDesc()) { + notifications.reverse(); + } + self.notifications(notifications); + }; + + self.clear = function () { + if (!self.loginState.hasPermission(self.access.permissions.PLUGIN_ACTION_COMMAND_NOTIFICATION_CLEAR)) + return; + + OctoPrint.plugins.action_command_notification.clear(); + }; + + self.toggleSorting = function () { + self.sortDesc(!self.sortDesc()); + self.requestData(); + }; + + self.onStartup = self.onUserLoggedIn = self.onUserLoggedOut = function () { + self.requestData(); + }; + + self.onDataUpdaterPluginMessage = function (plugin, data) { + if (!self.loginState.hasPermission(self.access.permissions.PLUGIN_ACTION_COMMAND_NOTIFICATION_SHOW)) return; + if (plugin !== "action_command_notification") { + return; + } + + self.requestData(); + + if (data.message && self.settings.settings.plugins.action_command_notification.enable_popups()) { + new PNotify({ + title: gettext("Printer Notification"), + text: data.message, + hide: false, + icon: "fa fa-bell-o", + buttons: { + sticker: false, + closer: true + } + }); + } + }; + + var optionsLocalStorageKey = "plugin.action_command_notification.options"; + self._toLocalStorage = function () { + saveToLocalStorage(optionsLocalStorageKey, {sortDesc: self.sortDesc()}); + }; + + self._fromLocalStorage = function () { + var data = loadFromLocalStorage(optionsLocalStorageKey); + if (data["sortDesc"] !== undefined) { + self.sortDesc(!!data["sortDesc"]); + } + }; + + self._fromLocalStorage(); + } + + OCTOPRINT_VIEWMODELS.push({ + construct: ActionCommandNotificationViewModel, + dependencies: ["loginStateViewModel", "accessViewModel", "settingsViewModel"], + elements: ["#sidebar_plugin_action_command_notification_wrapper"] + }); +}); diff --git a/src/octoprint/plugins/action_command_notification/templates/action_command_notification_settings.jinja2 b/src/octoprint/plugins/action_command_notification/templates/action_command_notification_settings.jinja2 new file mode 100644 index 0000000000..a5d079b9b6 --- /dev/null +++ b/src/octoprint/plugins/action_command_notification/templates/action_command_notification_settings.jinja2 @@ -0,0 +1,19 @@ +

{{ _('Action Command Notification Settings') }}

+ +
+
+
+ +
+
+
+
+ +
+
+
diff --git a/src/octoprint/plugins/action_command_notification/templates/action_command_notification_sidebar.jinja2 b/src/octoprint/plugins/action_command_notification/templates/action_command_notification_sidebar.jinja2 new file mode 100644 index 0000000000..c28fc03fe1 --- /dev/null +++ b/src/octoprint/plugins/action_command_notification/templates/action_command_notification_sidebar.jinja2 @@ -0,0 +1,12 @@ +
+ + +
+
+ {{ _('There are currently no notifications from your printer.') }} +
diff --git a/src/octoprint/plugins/action_command_notification/templates/action_command_notification_sidebar_header.jinja2 b/src/octoprint/plugins/action_command_notification/templates/action_command_notification_sidebar_header.jinja2 new file mode 100644 index 0000000000..d4b04232aa --- /dev/null +++ b/src/octoprint/plugins/action_command_notification/templates/action_command_notification_sidebar_header.jinja2 @@ -0,0 +1,11 @@ + + + diff --git a/src/octoprint/plugins/action_command_prompt/__init__.py b/src/octoprint/plugins/action_command_prompt/__init__.py new file mode 100644 index 0000000000..f494645f40 --- /dev/null +++ b/src/octoprint/plugins/action_command_prompt/__init__.py @@ -0,0 +1,304 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2018 The OctoPrint Project - Released under terms of the AGPLv3 License" + +import flask +from flask_babel import gettext + +import octoprint.plugin +from octoprint.access import USER_GROUP +from octoprint.access.permissions import Permissions +from octoprint.events import Events + + +class Prompt(object): + def __init__(self, text): + self.text = text + self.choices = [] + + self._active = False + + @property + def active(self): + return self._active + + def add_choice(self, text): + self.choices.append(text) + + def activate(self): + self._active = True + + def validate_choice(self, choice): + return 0 <= choice < len(self.choices) + + +class ActionCommandPromptPlugin( + octoprint.plugin.AssetPlugin, + octoprint.plugin.EventHandlerPlugin, + octoprint.plugin.SettingsPlugin, + octoprint.plugin.SimpleApiPlugin, + octoprint.plugin.TemplatePlugin, +): + + COMMAND = "M876" + CAP_PROMPT_SUPPORT = "PROMPT_SUPPORT" + + # noinspection PyMissingConstructor + def __init__(self): + self._prompt = None + self._enable = "detected" + self._command = None + self._enable_emergency_sending = True + self._enable_signal_support = True + self._cap_prompt_support = False + + def initialize(self): + self._enable = self._settings.get(["enable"]) + self._command = self._settings.get(["command"]) + self._enable_emergency_sending = self._settings.get_boolean( + ["enable_emergency_sending"] + ) + self._enable_signal_support = self._settings.get_boolean( + ["enable_signal_support"] + ) + + # Additional permissions hook + + def get_additional_permissions(self): + return [ + { + "key": "INTERACT", + "name": "Interact with printer prompts", + "description": gettext("Allows to see and interact with printer prompts"), + "default_groups": [USER_GROUP], + "roles": ["interact"], + } + ] + + # ~ AssetPlugin + + def get_assets(self): + return { + "js": ["js/action_command_prompt.js"], + "clientjs": ["clientjs/action_command_prompt.js"], + } + + # ~ EventHandlerPlugin + + def on_event(self, event, payload): + if ( + event == Events.CONNECTED + and self._enable == "always" + and self._enable_signal_support + ): + self._printer.commands(["{command} P1".format(command=self._command)]) + elif event == Events.DISCONNECTED: + self._close_prompt() + + # ~ SettingsPlugin + + def get_settings_defaults(self): + return { + "enable": "detected", + "command": self.COMMAND, + "enable_emergency_sending": True, + "enable_signal_support": True, + } + + def on_settings_save(self, data): + octoprint.plugin.SettingsPlugin.on_settings_save(self, data) + self._enable = self._settings.get(["enable"]) + self._command = self._settings.get(["command"]) + self._enable_emergency_sending = self._settings.get_boolean( + ["enable_emergency_sending"] + ) + self._enable_signal_support = self._settings.get_boolean( + ["enable_signal_support"] + ) + + # ~ SimpleApiPlugin + + def get_api_commands(self): + return {"select": ["choice"]} + + def on_api_command(self, command, data): + if command == "select": + if not Permissions.PLUGIN_ACTION_COMMAND_PROMPT_INTERACT.can(): + return flask.abort(403) + + if self._prompt is None: + return flask.abort(409, description="No active prompt") + + choice = data["choice"] + if not isinstance(choice, int) or not self._prompt.validate_choice(choice): + return flask.abort( + 400, "{!r} is not a valid value for choice".format(choice) + ) + + self._answer_prompt(choice) + + def on_api_get(self, request): + if not Permissions.PLUGIN_ACTION_COMMAND_PROMPT_INTERACT.can(): + return flask.abort(403) + if self._prompt is None: + return flask.jsonify() + else: + return flask.jsonify(text=self._prompt.text, choices=self._prompt.choices) + + # ~ TemplatePlugin + + def get_template_configs(self): + return [ + { + "type": "settings", + "name": gettext("Printer Dialogs"), + "custom_bindings": False, + } + ] + + # ~ action command handler + + def action_command_handler(self, comm, line, action, *args, **kwargs): + if not action.startswith("prompt_"): + return + + parts = action.split(None, 1) + if len(parts) == 1: + action = parts[0] + parameter = "" + else: + action, parameter = parts + + if action == "prompt_begin": + if self._prompt is not None and self._prompt.active: + self._logger.warning("Prompt is already defined") + return + self._prompt = Prompt(parameter.strip()) + + elif action == "prompt_choice" or action == "prompt_button": + if self._prompt is None: + return + if self._prompt.active: + self._logger.warning("Prompt is already active") + return + self._prompt.add_choice(parameter.strip()) + + elif action == "prompt_show": + if self._prompt is None: + return + if self._prompt.active: + self._logger.warning("Prompt is already active") + return + self._show_prompt() + + elif action == "prompt_end": + if self._prompt is None: + return + self._close_prompt() + self._prompt = None + + # ~ queuing handling + + def gcode_queuing_handler( + self, + comm_instance, + phase, + cmd, + cmd_type, + gcode, + subcode=None, + tags=None, + *args, + **kwargs + ): + if gcode != self._command: + return + + if ( + self._enable == "never" + or (self._enable == "detected" and not self._cap_prompt_support) + or not self._enable_emergency_sending + ): + return + + if "S" not in cmd: + # we only force-send M876 Sx + return + + # noinspection PyProtectedMember + return comm_instance._emergency_force_send( + cmd, "Force-sending {} to the printer".format(self._command), gcode=gcode + ) + + # ~ capability reporting + + def firmware_capability_handler( + self, comm_instance, capability, enabled, already_defined, *args, **kwargs + ): + if capability == self.CAP_PROMPT_SUPPORT and enabled: + self._cap_prompt_support = True + if self._enable == "detected" and self._enable_signal_support: + self._printer.commands(["{command} P1".format(command=self._command)]) + + # ~ prompt handling + + def _show_prompt(self): + if self._enable == "never" or ( + self._enable == "detected" and not self._cap_prompt_support + ): + return + + self._prompt.activate() + self._plugin_manager.send_plugin_message( + self._identifier, + { + "action": "show", + "text": self._prompt.text, + "choices": self._prompt.choices, + }, + ) + + def _close_prompt(self): + if self._enable == "never" or ( + self._enable == "detected" and not self._cap_prompt_support + ): + return + + self._prompt = None + self._plugin_manager.send_plugin_message(self._identifier, {"action": "close"}) + + def _answer_prompt(self, choice): + if self._enable == "never" or ( + self._enable == "detected" and not self._cap_prompt_support + ): + return + + self._close_prompt() + if "{choice}" in self._command: + self._printer.commands([self._command.format(choice=choice)], force=True) + else: + self._printer.commands( + ["{command} S{choice}".format(command=self._command, choice=choice)], + force=True, + ) + + +__plugin_name__ = "Action Command Prompt Support" +__plugin_description__ = ( + "Allows your printer to trigger prompts via action commands on the connection" +) +__plugin_author__ = "Gina Häußge" +__plugin_disabling_discouraged__ = gettext( + "Without this plugin your printer will no longer be able to trigger" + " confirmation or selection prompts in OctoPrint" +) +__plugin_license__ = "AGPLv3" +__plugin_pythoncompat__ = ">=2.7,<4" +__plugin_implementation__ = ActionCommandPromptPlugin() +__plugin_hooks__ = { + "octoprint.comm.protocol.action": __plugin_implementation__.action_command_handler, + "octoprint.comm.protocol.gcode.queuing": __plugin_implementation__.gcode_queuing_handler, + "octoprint.comm.protocol.firmware.capabilities": __plugin_implementation__.firmware_capability_handler, + "octoprint.access.permissions": __plugin_implementation__.get_additional_permissions, +} diff --git a/src/octoprint/plugins/action_command_prompt/static/clientjs/action_command_prompt.js b/src/octoprint/plugins/action_command_prompt/static/clientjs/action_command_prompt.js new file mode 100644 index 0000000000..bb86f7c533 --- /dev/null +++ b/src/octoprint/plugins/action_command_prompt/static/clientjs/action_command_prompt.js @@ -0,0 +1,25 @@ +(function (global, factory) { + if (typeof define === "function" && define.amd) { + define(["OctoPrintClient"], factory); + } else { + factory(global.OctoPrintClient); + } +})(this, function (OctoPrintClient) { + var OctoPrintActionCommandPromptClient = function (base) { + this.base = base; + }; + + OctoPrintActionCommandPromptClient.prototype.get = function (refresh, opts) { + return this.base.get(this.base.getSimpleApiUrl("action_command_prompt"), opts); + }; + + OctoPrintActionCommandPromptClient.prototype.select = function (choice, opts) { + var data = { + choice: choice + }; + return this.base.simpleApiCommand("action_command_prompt", "select", data, opts); + }; + + OctoPrintClient.registerPluginComponent("action_command_prompt", OctoPrintActionCommandPromptClient); + return OctoPrintActionCommandPromptClient; +}); diff --git a/src/octoprint/plugins/action_command_prompt/static/js/action_command_prompt.js b/src/octoprint/plugins/action_command_prompt/static/js/action_command_prompt.js new file mode 100644 index 0000000000..26b3cc751e --- /dev/null +++ b/src/octoprint/plugins/action_command_prompt/static/js/action_command_prompt.js @@ -0,0 +1,102 @@ +$(function () { + function ActionCommandPromptViewModel(parameters) { + var self = this; + + self.loginState = parameters[0]; + self.access = parameters[1]; + + self.modal = ko.observable(undefined); + + self.text = ko.observable(); + self.buttons = ko.observableArray([]); + + self.active = ko.pureComputed(function () { + return self.text() !== undefined; + }); + self.visible = ko.pureComputed(function () { + return self.modal() !== undefined; + }); + + self.requestData = function () { + if (!self.loginState.hasPermission(self.access.permissions.PLUGIN_ACTION_COMMAND_PROMPT_INTERACT)) return; + + OctoPrint.plugins.action_command_prompt.get().done(self.fromResponse); + }; + + self.fromResponse = function (data) { + if (data.hasOwnProperty("text") && data.hasOwnProperty("choices")) { + self.text(data.text); + self.buttons(data.choices); + self.showPrompt(); + } else { + self.text(undefined); + self.buttons([]); + } + }; + + self.showPrompt = function () { + var text = self.text(); + var buttons = self.buttons(); + + var opts = { + title: gettext("Message from your printer"), + message: text, + selections: buttons, + maycancel: true, // see #3171 + onselect: function (index) { + if (index > -1) { + self._select(index); + } + }, + onclose: function () { + self.modal(undefined); + } + }; + + self.modal(showSelectionDialog(opts)); + }; + + self._select = function (index) { + OctoPrint.plugins.action_command_prompt.select(index); + }; + + self._closePrompt = function () { + var modal = self.modal(); + if (modal) { + modal.modal("hide"); + } + }; + + self.onStartupComplete = function () { + self.requestData(); + }; + + self.onDataUpdaterPluginMessage = function (plugin, data) { + if (!self.loginState.hasPermission(self.access.permissions.PLUGIN_ACTION_COMMAND_PROMPT_INTERACT)) return; + if (plugin !== "action_command_prompt") { + return; + } + + switch (data.action) { + case "show": { + self.text(data.text); + self.buttons(data.choices); + self.showPrompt(); + break; + } + case "close": { + self.text(undefined); + self.buttons([]); + self._closePrompt(); + break; + } + } + }; + } + + OCTOPRINT_VIEWMODELS.push({ + construct: ActionCommandPromptViewModel, + dependencies: ["loginStateViewModel", "accessViewModel"], + elements: ["#navbar_plugin_action_command_prompt"] + }); +}); diff --git a/src/octoprint/plugins/action_command_prompt/templates/action_command_prompt_navbar.jinja2 b/src/octoprint/plugins/action_command_prompt/templates/action_command_prompt_navbar.jinja2 new file mode 100644 index 0000000000..c9f903f73c --- /dev/null +++ b/src/octoprint/plugins/action_command_prompt/templates/action_command_prompt_navbar.jinja2 @@ -0,0 +1,3 @@ + diff --git a/src/octoprint/plugins/action_command_prompt/templates/action_command_prompt_settings.jinja2 b/src/octoprint/plugins/action_command_prompt/templates/action_command_prompt_settings.jinja2 new file mode 100644 index 0000000000..109877965a --- /dev/null +++ b/src/octoprint/plugins/action_command_prompt/templates/action_command_prompt_settings.jinja2 @@ -0,0 +1,44 @@ +

{{ _('Action Command Prompt Settings') }}

+ +
+
+ +
+ + + +
+
+
+ +
+
+ +
+ +
{{ _('Use this to define the dialog command. Default is M876. You normally should not have to change this.') }}
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
diff --git a/src/octoprint/plugins/announcements/__init__.py b/src/octoprint/plugins/announcements/__init__.py index c5d6935bd6..01f5dd6912 100644 --- a/src/octoprint/plugins/announcements/__init__.py +++ b/src/octoprint/plugins/announcements/__init__.py @@ -1,497 +1,638 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals __author__ = "Gina Häußge " -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2016 The OctoPrint Project - Released under terms of the AGPLv3 License" -import octoprint.plugin - import calendar -import codecs +import io import os import re -import time +import sys import threading +import time +from collections import OrderedDict import feedparser import flask +from flask_babel import gettext -from collections import OrderedDict - -from octoprint.server import admin_permission -from octoprint.server.util.flask import restricted_access, with_revalidation_checking, check_etag -from octoprint.util import utmify -from flask.ext.babel import gettext +import octoprint.plugin from octoprint import __version__ as OCTOPRINT_VERSION +from octoprint.access import ADMIN_GROUP +from octoprint.access.permissions import Permissions +from octoprint.server.util.flask import ( + check_etag, + no_firstrun_access, + with_revalidation_checking, +) +from octoprint.util import count, monotonic_time, utmify +from octoprint.util.text import sanitize + +PY2 = sys.version_info[0] < 3 + + +class AnnouncementPlugin( + octoprint.plugin.AssetPlugin, + octoprint.plugin.SettingsPlugin, + octoprint.plugin.BlueprintPlugin, + octoprint.plugin.StartupPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.EventHandlerPlugin, +): + + # noinspection PyMissingConstructor + def __init__(self): + self._cached_channel_configs = None + self._cached_channel_configs_mutex = threading.RLock() + + # Additional permissions hook + + def get_additional_permissions(self): + return [ + { + "key": "READ", + "name": "Read announcements", + "description": gettext("Allows to read announcements"), + "default_groups": [ADMIN_GROUP], + "roles": ["read"], + }, + { + "key": "MANAGE", + "name": "Manage announcement subscriptions", + "description": gettext( + 'Allows to manage announcement subscriptions. Includes "Read announcements" ' + "permission" + ), + "default_groups": [ADMIN_GROUP], + "roles": ["manage"], + "permissions": ["PLUGIN_ANNOUNCEMENTS_READ"], + }, + ] + + # StartupPlugin + + def on_after_startup(self): + # decouple channel fetching from server startup + def fetch_data(): + self._fetch_all_channels() + + thread = threading.Thread(target=fetch_data) + thread.daemon = True + thread.start() + + # SettingsPlugin + + def get_settings_defaults(self): + settings = { + "channels": { + "_important": { + "name": "Important Announcements", + "description": "Important announcements about OctoPrint.", + "priority": 1, + "type": "rss", + "url": "https://octoprint.org/feeds/important.xml", + }, + "_releases": { + "name": "Release Announcements", + "description": "Announcements of new releases and release candidates of OctoPrint.", + "priority": 2, + "type": "rss", + "url": "https://octoprint.org/feeds/releases.xml", + }, + "_blog": { + "name": "On the OctoBlog", + "description": "Development news, community spotlights, OctoPrint On Air episodes and more from the official OctoBlog.", + "priority": 2, + "type": "rss", + "url": "https://octoprint.org/feeds/octoblog.xml", + }, + "_plugins": { + "name": "New Plugins in the Repository", + "description": "Announcements of new plugins released on the official Plugin Repository.", + "priority": 2, + "type": "rss", + "url": "https://plugins.octoprint.org/feed.xml", + }, + "_octopi": { + "name": "OctoPi News", + "description": "News around OctoPi, the Raspberry Pi image including OctoPrint.", + "priority": 2, + "type": "rss", + "url": "https://octoprint.org/feeds/octopi.xml", + }, + }, + "enabled_channels": [], + "forced_channels": ["_important"], + "channel_order": ["_important", "_releases", "_blog", "_plugins", "_octopi"], + "ttl": 6 * 60, + "display_limit": 3, + "summary_limit": 300, + } + settings["enabled_channels"] = list(settings["channels"].keys()) + return settings + + def get_settings_version(self): + return 1 + + def on_settings_migrate(self, target, current): + if current is None: + # first version had different default feeds and only _important enabled by default + channels = self._settings.get(["channels"]) + if "_news" in channels: + del channels["_news"] + if "_spotlight" in channels: + del channels["_spotlight"] + self._settings.set(["channels"], channels) + + enabled = self._settings.get(["enabled_channels"]) + add_blog = False + if "_news" in enabled: + add_blog = True + enabled.remove("_news") + if "_spotlight" in enabled: + add_blog = True + enabled.remove("_spotlight") + if add_blog and "_blog" not in enabled: + enabled.append("_blog") + self._settings.set(["enabled_channels"], enabled) + + # AssetPlugin + + def get_assets(self): + return { + "js": ["js/announcements.js"], + "less": ["less/announcements.less"], + "css": ["css/announcements.css"], + } + + # Template Plugin + + def get_template_configs(self): + return [ + { + "type": "settings", + "name": gettext("Announcements"), + "template": "announcements_settings.jinja2", + "custom_bindings": True, + }, + { + "type": "navbar", + "template": "announcements_navbar.jinja2", + "styles": ["display: none"], + "data_bind": "visible: loginState.hasPermission(access.permissions.PLUGIN_ANNOUNCEMENTS_READ)", + }, + ] + + # Blueprint Plugin + + @octoprint.plugin.BlueprintPlugin.route("/channels", methods=["GET"]) + @no_firstrun_access + @Permissions.PLUGIN_ANNOUNCEMENTS_READ.require(403) + def get_channel_data(self): + from octoprint.settings import valid_boolean_trues + + result = [] + + force = flask.request.values.get("force", "false") in valid_boolean_trues + + enabled = self._settings.get(["enabled_channels"]) + forced = self._settings.get(["forced_channels"]) + + channel_configs = self._get_channel_configs(force=force) + + def view(): + channel_data = self._fetch_all_channels(force=force) + + for key, data in channel_configs.items(): + read_until = channel_configs[key].get("read_until", None) + entries = sorted( + self._to_internal_feed( + channel_data.get(key, []), read_until=read_until + ), + key=lambda e: e["published"], + reverse=True, + ) + unread = count(filter(lambda e: not e["read"], entries)) + + if read_until is None and entries: + last = entries[0]["published"] + self._mark_read_until(key, last) + + result.append( + { + "key": key, + "channel": data["name"], + "url": data["url"], + "description": data.get("description", ""), + "priority": data.get("priority", 2), + "enabled": key in enabled or key in forced, + "forced": key in forced, + "data": entries, + "unread": unread, + } + ) + + return flask.jsonify(channels=result) + + def etag(): + import hashlib + + hash = hashlib.sha1() + + def hash_update(value): + hash.update(value.encode("utf-8")) + + hash_update(repr(sorted(enabled))) + hash_update(repr(sorted(forced))) + hash_update(OCTOPRINT_VERSION) + + for channel in sorted(channel_configs.keys()): + hash_update(repr(channel_configs[channel])) + channel_data = self._get_channel_data_from_cache( + channel, channel_configs[channel] + ) + hash_update(repr(channel_data)) + + return hash.hexdigest() + + # noinspection PyShadowingNames + def condition(lm, etag): + return check_etag(etag) + + return with_revalidation_checking( + etag_factory=lambda *args, **kwargs: etag(), + condition=lambda lm, etag: condition(lm, etag), + unless=lambda: force, + )(view)() + + @octoprint.plugin.BlueprintPlugin.route("/channels/", methods=["POST"]) + @no_firstrun_access + @Permissions.PLUGIN_ANNOUNCEMENTS_READ.require(403) + def channel_command(self, channel): + from octoprint.server import NO_CONTENT + from octoprint.server.util.flask import get_json_command_from_request + + valid_commands = {"read": ["until"], "toggle": []} + + command, data, response = get_json_command_from_request( + flask.request, valid_commands=valid_commands + ) + if response is not None: + return response + + if command == "read": + until = data["until"] + self._mark_read_until(channel, until) + + elif command == "toggle": + if not Permissions.PLUGIN_ANNOUNCEMENTS_MANAGE.can(): + flask.abort(403) + self._toggle(channel) + + return NO_CONTENT + + def is_blueprint_protected(self): + return False + + ##~~ EventHandlerPlugin + + def on_event(self, event, payload): + from octoprint.events import Events + + if ( + event != Events.CONNECTIVITY_CHANGED + or not payload + or not payload.get("new", False) + ): + return + self._fetch_all_channels_async() + + # Internal Tools + + def _mark_read_until(self, channel, until): + """Set read_until timestamp of a channel.""" + + current_read_until = None + channel_data = self._settings.get(["channels", channel], merged=True) + if channel_data: + current_read_until = channel_data.get("read_until", None) + + defaults = {"plugins": {"announcements": {"channels": {}}}} + defaults["plugins"]["announcements"]["channels"][channel] = { + "read_until": current_read_until + } + + with self._cached_channel_configs_mutex: + self._settings.set( + ["channels", channel, "read_until"], until, defaults=defaults + ) + self._settings.save() + self._cached_channel_configs = None + + def _toggle(self, channel): + """Toggle enable/disabled state of a channel.""" + + enabled_channels = list(self._settings.get(["enabled_channels"])) + + if channel in enabled_channels: + enabled_channels.remove(channel) + else: + enabled_channels.append(channel) + + self._settings.set(["enabled_channels"], enabled_channels) + self._settings.save() + + def _get_channel_configs(self, force=False): + """Retrieve all channel configs with sanitized keys.""" + + with self._cached_channel_configs_mutex: + if self._cached_channel_configs is None or force: + configs = self._settings.get(["channels"], merged=True) + order = self._settings.get(["channel_order"]) + all_keys = order + [ + key for key in sorted(configs.keys()) if key not in order + ] + + result = OrderedDict() + for key in all_keys: + config = configs.get(key) + if config is None or "url" not in config or "name" not in config: + # strip invalid entries + continue + result[sanitize(key)] = config + self._cached_channel_configs = result + return self._cached_channel_configs + + def _get_channel_config(self, key, force=False): + """Retrieve specific channel config for channel.""" + + safe_key = sanitize(key) + return self._get_channel_configs(force=force).get(safe_key) + + def _fetch_all_channels_async(self, force=False): + thread = threading.Thread( + target=self._fetch_all_channels, kwargs={"force": force} + ) + thread.daemon = True + thread.start() + + def _fetch_all_channels(self, force=False): + """Fetch all channel feeds from cache or network.""" + + channels = self._get_channel_configs(force=force) + enabled = self._settings.get(["enabled_channels"]) + forced = self._settings.get(["forced_channels"]) + + all_channels = {} + for key, config in channels.items(): + if key not in enabled and key not in forced: + continue + + if "url" not in config: + continue + + data = self._get_channel_data(key, config, force=force) + if data is not None: + all_channels[key] = data + + return all_channels + + def _get_channel_data(self, key, config, force=False): + """Fetch individual channel feed from cache/network.""" + + data = None + + if not force: + # we may use the cache, see if we have something in there + data = self._get_channel_data_from_cache(key, config) + + if data is None: + # cache not allowed or empty, fetch from network + if self._connectivity_checker.online: + data = self._get_channel_data_from_network(key, config) + else: + self._logger.info( + "Looks like we are offline, can't fetch announcements for channel {} from network".format( + key + ) + ) + + return data + + def _get_channel_data_from_cache(self, key, config): + """Fetch channel feed from cache.""" + + channel_path = self._get_channel_cache_path(key) + + if os.path.exists(channel_path): + if "ttl" in config and isinstance(config["ttl"], int): + ttl = config["ttl"] + else: + ttl = self._settings.get_int(["ttl"]) + + ttl *= 60 + now = time.time() + if os.stat(channel_path).st_mtime + ttl > now: + d = feedparser.parse(channel_path) + self._logger.debug( + "Loaded channel {} from cache at {}".format(key, channel_path) + ) + return d + + return None + + def _get_channel_data_from_network(self, key, config): + """Fetch channel feed from network.""" + + import requests + + url = config["url"] + try: + start = monotonic_time() + r = requests.get(url, timeout=30) + r.raise_for_status() + self._logger.info( + "Loaded channel {} from {} in {:.2}s".format( + key, config["url"], monotonic_time() - start + ) + ) + except Exception: + self._logger.exception( + "Could not fetch channel {} from {}".format(key, config["url"]) + ) + return None + + response = r.text + channel_path = self._get_channel_cache_path(key) + with io.open(channel_path, mode="wt", encoding="utf-8") as f: + f.write(response) + return feedparser.parse(response) + + def _to_internal_feed(self, feed, read_until=None): + """Convert feed to internal data structure.""" + + result = [] + if "entries" in feed: + for entry in feed["entries"]: + try: + internal_entry = self._to_internal_entry(entry, read_until=read_until) + if internal_entry: + result.append(internal_entry) + except Exception: + self._logger.exception( + "Error while converting entry from feed, skipping it" + ) + return result + + def _to_internal_entry(self, entry, read_until=None): + """Convert feed entries to internal data structure.""" + + timestamp = entry.get("published_parsed", None) + if timestamp is None: + timestamp = entry.get("updated_parsed", None) + if timestamp is None: + return None + + published = calendar.timegm(timestamp) + + read = True + if read_until is not None: + read = published <= read_until + + return { + "title": entry["title"], + "title_without_tags": _strip_tags(entry["title"]), + "summary": _lazy_images(entry["summary"]), + "summary_without_images": _strip_images(entry["summary"]), + "published": published, + "link": utmify( + entry["link"], + source="octoprint", + medium="announcements", + content=OCTOPRINT_VERSION, + ), + "read": read, + } + + def _get_channel_cache_path(self, key): + """Retrieve cache path for channel key.""" + + safe_key = sanitize(key) + return os.path.join(self.get_plugin_data_folder(), "{}.cache".format(safe_key)) + + +_image_tag_re = re.compile(r"") -class AnnouncementPlugin(octoprint.plugin.AssetPlugin, - octoprint.plugin.SettingsPlugin, - octoprint.plugin.BlueprintPlugin, - octoprint.plugin.StartupPlugin, - octoprint.plugin.TemplatePlugin, - octoprint.plugin.EventHandlerPlugin): - - # noinspection PyMissingConstructor - def __init__(self): - self._cached_channel_configs = None - self._cached_channel_configs_mutex = threading.RLock() - - from slugify import Slugify - self._slugify = Slugify() - self._slugify.safe_chars = "-_." - - # StartupPlugin - - def on_after_startup(self): - # decouple channel fetching from server startup - def fetch_data(): - self._fetch_all_channels() - - thread = threading.Thread(target=fetch_data) - thread.daemon = True - thread.start() - - # SettingsPlugin - - def get_settings_defaults(self): - settings = dict(channels=dict(_important=dict(name="Important Announcements", - description="Important announcements about OctoPrint.", - priority=1, - type="rss", - url="https://octoprint.org/feeds/important.xml"), - _releases=dict(name="Release Announcements", - description="Announcements of new releases and release candidates of OctoPrint.", - priority=2, - type="rss", - url="https://octoprint.org/feeds/releases.xml"), - _blog=dict(name="On the OctoBlog", - description="Development news, community spotlights, OctoPrint On Air episodes and more from the official OctoBlog.", - priority=2, - type="rss", - url="https://octoprint.org/feeds/octoblog.xml"), - _plugins=dict(name="New Plugins in the Repository", - description="Announcements of new plugins released on the official Plugin Repository.", - priority=2, - type="rss", - url="https://plugins.octoprint.org/feed.xml"), - _octopi=dict(name="OctoPi News", - description="News around OctoPi, the Raspberry Pi image including OctoPrint.", - priority=2, - type="rss", - url="https://octoprint.org/feeds/octopi.xml")), - enabled_channels=[], - forced_channels=["_important"], - channel_order=["_important", "_releases", "_blog", "_plugins", "_octopi"], - ttl=6*60, - display_limit=3, - summary_limit=300) - settings["enabled_channels"] = settings["channels"].keys() - return settings - - def get_settings_version(self): - return 1 - - def on_settings_migrate(self, target, current): - if current is None: - # first version had different default feeds and only _important enabled by default - channels = self._settings.get(["channels"]) - if "_news" in channels: - del channels["_news"] - if "_spotlight" in channels: - del channels["_spotlight"] - self._settings.set(["channels"], channels) - - enabled = self._settings.get(["enabled_channels"]) - add_blog = False - if "_news" in enabled: - add_blog = True - enabled.remove("_news") - if "_spotlight" in enabled: - add_blog = True - enabled.remove("_spotlight") - if add_blog and not "_blog" in enabled: - enabled.append("_blog") - self._settings.set(["enabled_channels"], enabled) - - # AssetPlugin - - def get_assets(self): - return dict(js=["js/announcements.js"], - less=["less/announcements.less"], - css=["css/announcements.css"]) - - # Template Plugin - - def get_template_configs(self): - return [ - dict(type="settings", name=gettext("Announcements"), template="announcements_settings.jinja2", custom_bindings=True), - dict(type="navbar", template="announcements_navbar.jinja2", styles=["display: none"], data_bind="visible: loginState.isAdmin") - ] - - # Blueprint Plugin - - @octoprint.plugin.BlueprintPlugin.route("/channels", methods=["GET"]) - @restricted_access - @admin_permission.require(403) - def get_channel_data(self): - from octoprint.settings import valid_boolean_trues - - result = [] - - force = flask.request.values.get("force", "false") in valid_boolean_trues - - enabled = self._settings.get(["enabled_channels"]) - forced = self._settings.get(["forced_channels"]) - - channel_configs = self._get_channel_configs(force=force) - - def view(): - channel_data = self._fetch_all_channels(force=force) - - for key, data in channel_configs.items(): - read_until = channel_configs[key].get("read_until", None) - entries = sorted(self._to_internal_feed(channel_data.get(key, []), read_until=read_until), key=lambda e: e["published"], reverse=True) - unread = len(filter(lambda e: not e["read"], entries)) - - if read_until is None and entries: - last = entries[0]["published"] - self._mark_read_until(key, last) - - result.append(dict(key=key, - channel=data["name"], - url=data["url"], - description=data.get("description", ""), - priority=data.get("priority", 2), - enabled=key in enabled or key in forced, - forced=key in forced, - data=entries, - unread=unread)) - - return flask.jsonify(channels=result) - - def etag(): - import hashlib - hash = hashlib.sha1() - hash.update(repr(sorted(enabled))) - hash.update(repr(sorted(forced))) - hash.update(OCTOPRINT_VERSION) - - for channel in sorted(channel_configs.keys()): - hash.update(repr(channel_configs[channel])) - channel_data = self._get_channel_data_from_cache(channel, channel_configs[channel]) - hash.update(repr(channel_data)) - - return hash.hexdigest() - - # noinspection PyShadowingNames - def condition(lm, etag): - return check_etag(etag) - - return with_revalidation_checking(etag_factory=lambda *args, **kwargs: etag(), - condition=lambda lm, etag: condition(lm, etag), - unless=lambda: force)(view)() - - @octoprint.plugin.BlueprintPlugin.route("/channels/", methods=["POST"]) - @restricted_access - @admin_permission.require(403) - def channel_command(self, channel): - from octoprint.server.util.flask import get_json_command_from_request - from octoprint.server import NO_CONTENT - - valid_commands = dict(read=["until"], - toggle=[]) - - command, data, response = get_json_command_from_request(flask.request, valid_commands=valid_commands) - if response is not None: - return response - - if command == "read": - until = data["until"] - self._mark_read_until(channel, until) - - elif command == "toggle": - self._toggle(channel) - - return NO_CONTENT - - ##~~ EventHandlerPlugin - - def on_event(self, event, payload): - from octoprint.events import Events - if event != Events.CONNECTIVITY_CHANGED or not payload or not payload.get("new", False): - return - self._fetch_all_channels_async() - - # Internal Tools - - def _mark_read_until(self, channel, until): - """Set read_until timestamp of a channel.""" - - current_read_until = None - channel_data = self._settings.get(["channels", channel], merged=True) - if channel_data: - current_read_until = channel_data.get("read_until", None) - - defaults = dict(plugins=dict(announcements=dict(channels=dict()))) - defaults["plugins"]["announcements"]["channels"][channel] = dict(read_until=current_read_until) - - with self._cached_channel_configs_mutex: - self._settings.set(["channels", channel, "read_until"], until, defaults=defaults) - self._settings.save() - self._cached_channel_configs = None - - def _toggle(self, channel): - """Toggle enable/disabled state of a channel.""" - - enabled_channels = list(self._settings.get(["enabled_channels"])) - - if channel in enabled_channels: - enabled_channels.remove(channel) - else: - enabled_channels.append(channel) - - self._settings.set(["enabled_channels"], enabled_channels) - self._settings.save() - - def _get_channel_configs(self, force=False): - """Retrieve all channel configs with sanitized keys.""" - - with self._cached_channel_configs_mutex: - if self._cached_channel_configs is None or force: - configs = self._settings.get(["channels"], merged=True) - order = self._settings.get(["channel_order"]) - all_keys = order + [key for key in sorted(configs.keys()) if not key in order] - - result = OrderedDict() - for key in all_keys: - config = configs.get(key) - if config is None or "url" not in config or "name" not in config: - # strip invalid entries - continue - result[self._slugify(key)] = config - self._cached_channel_configs = result - return self._cached_channel_configs - - def _get_channel_config(self, key, force=False): - """Retrieve specific channel config for channel.""" - - safe_key = self._slugify(key) - return self._get_channel_configs(force=force).get(safe_key) - - def _fetch_all_channels_async(self, force=False): - thread = threading.Thread(target=self._fetch_all_channels, kwargs=dict(force=force)) - thread.daemon = True - thread.start() - - def _fetch_all_channels(self, force=False): - """Fetch all channel feeds from cache or network.""" - - channels = self._get_channel_configs(force=force) - enabled = self._settings.get(["enabled_channels"]) - forced = self._settings.get(["forced_channels"]) - - all_channels = dict() - for key, config in channels.items(): - if not key in enabled and not key in forced: - continue - if not "url" in config: - continue - - data = self._get_channel_data(key, config, force=force) - if data is not None: - all_channels[key] = data - - return all_channels - - def _get_channel_data(self, key, config, force=False): - """Fetch individual channel feed from cache/network.""" - - data = None - - if not force: - # we may use the cache, see if we have something in there - data = self._get_channel_data_from_cache(key, config) - - if data is None: - # cache not allowed or empty, fetch from network - if self._connectivity_checker.online: - data = self._get_channel_data_from_network(key, config) - else: - self._logger.info("Looks like we are offline, can't fetch announcements for channel {} from network".format(key)) - - return data - - def _get_channel_data_from_cache(self, key, config): - """Fetch channel feed from cache.""" - - channel_path = self._get_channel_cache_path(key) - - if os.path.exists(channel_path): - if "ttl" in config and isinstance(config["ttl"], int): - ttl = config["ttl"] - else: - ttl = self._settings.get_int(["ttl"]) - - ttl *= 60 - now = time.time() - if os.stat(channel_path).st_mtime + ttl > now: - d = feedparser.parse(channel_path) - self._logger.debug(u"Loaded channel {} from cache at {}".format(key, channel_path)) - return d - - return None - - def _get_channel_data_from_network(self, key, config): - """Fetch channel feed from network.""" - - import requests - - url = config["url"] - try: - start = time.time() - r = requests.get(url, timeout=30) - r.raise_for_status() - self._logger.info(u"Loaded channel {} from {} in {:.2}s".format(key, config["url"], time.time() - start)) - except Exception as e: - self._logger.exception( - u"Could not fetch channel {} from {}: {}".format(key, config["url"], str(e))) - return None - - response = r.text - channel_path = self._get_channel_cache_path(key) - with codecs.open(channel_path, mode="w", encoding="utf-8") as f: - f.write(response) - return feedparser.parse(response) - - def _to_internal_feed(self, feed, read_until=None): - """Convert feed to internal data structure.""" - - result = [] - if "entries" in feed: - for entry in feed["entries"]: - internal_entry = self._to_internal_entry(entry, read_until=read_until) - if internal_entry: - result.append(internal_entry) - return result - - def _to_internal_entry(self, entry, read_until=None): - """Convert feed entries to internal data structure.""" - - published = calendar.timegm(entry["published_parsed"]) - - read = True - if read_until is not None: - read = published <= read_until +def _strip_images(text): + """ + >>> _strip_images("I'm a link and this is an image: foo") # doctest: +ALLOW_UNICODE + "I'm a link and this is an image: " + >>> _strip_images("One and two and three and four \\"four\\"") # doctest: +ALLOW_UNICODE + 'One and two and three and four ' + >>> _strip_images("No images here") # doctest: +ALLOW_UNICODE + 'No images here' + """ + return _image_tag_re.sub("", text) - return dict(title=entry["title"], - title_without_tags=_strip_tags(entry["title"]), - summary=_lazy_images(entry["summary"]), - summary_without_images=_strip_images(entry["summary"]), - published=published, - link=utmify(entry["link"], source="octoprint", medium="announcements", content=OCTOPRINT_VERSION), - read=read) - def _get_channel_cache_path(self, key): - """Retrieve cache path for channel key.""" +def _replace_images(text, callback): + """ + >>> callback = lambda img: "foobar" + >>> _replace_images("I'm a link and this is an image: foo", callback) # doctest: +ALLOW_UNICODE + "I'm a link and this is an image: foobar" + >>> _replace_images("One and two and three and four \\"four\\"", callback) # doctest: +ALLOW_UNICODE + 'One foobar and two foobar and three foobar and four foobar' + """ + result = text + for match in _image_tag_re.finditer(text): + tag = match.group(0) + replaced = callback(tag) + result = result.replace(tag, replaced) + return result - safe_key = self._slugify(key) - return os.path.join(self.get_plugin_data_folder(), "{}.cache".format(safe_key)) +_image_src_re = re.compile(r'src=(?P[\'"]*)(?P.*?)(?P=quote)(?=\s+|>)') -_image_tag_re = re.compile(r'') -def _strip_images(text): - """ - >>> _strip_images(u"I'm a link and this is an image: foo") - u"I'm a link and this is an image: " - >>> _strip_images(u"One and two and three and four \\"four\\"") - u'One and two and three and four ' - >>> _strip_images(u"No images here") - u'No images here' - """ - return _image_tag_re.sub('', text) -def _replace_images(text, callback): - """ - >>> callback = lambda img: "foobar" - >>> _replace_images(u"I'm a link and this is an image: foo", callback) - u"I'm a link and this is an image: foobar" - >>> _replace_images(u"One and two and three and four \\"four\\"", callback) - u'One foobar and two foobar and three foobar and four foobar' - """ - result = text - for match in _image_tag_re.finditer(text): - tag = match.group(0) - replaced = callback(tag) - result = result.replace(tag, replaced) - return result - -_image_src_re = re.compile(r'src=(?P[\'"]*)(?P.*?)(?P=quote)(?=\s+|>)') def _lazy_images(text, placeholder=None): - """ - >>> _lazy_images(u"I'm a link and this is an image: foo") - u'I\\'m a link and this is an image: \\'foo\\'' - >>> _lazy_images(u"I'm a link and this is an image: foo", placeholder="ph.png") - u'I\\'m a link and this is an image: \\'foo\\'' - >>> _lazy_images(u"One and two and three and four \\"four\\"", placeholder="ph.png") - u'One and two and three and four four' - >>> _lazy_images(u"No images here") - u'No images here' - """ - if placeholder is None: - # 1px transparent gif - placeholder = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" - - def callback(img_tag): - match = _image_src_re.search(img_tag) - if match is not None: - src = match.group("src") - quote = match.group("quote") - quoted_src = quote + src + quote - img_tag = img_tag.replace(match.group(0), 'src="{}" data-src={}'.format(placeholder, quoted_src)) - return img_tag - - return _replace_images(text, callback) - -def _strip_tags(text): - """ - >>> _strip_tags(u"Hello world<img src='foo.jpg'>") - u"Hello world<img src='foo.jpg'>" - >>> _strip_tags(u"> > Foo") - u'> > Foo' - """ + """ + >>> _lazy_images("I'm a link and this is an image: foo") # doctest: +ALLOW_UNICODE + 'I\\'m a link and this is an image: \\'foo\\'' + >>> _lazy_images("I'm a link and this is an image: foo", placeholder="ph.png") # doctest: +ALLOW_UNICODE + 'I\\'m a link and this is an image: \\'foo\\'' + >>> _lazy_images("One and two and three and four \\"four\\"", placeholder="ph.png") # doctest: +ALLOW_UNICODE + 'One and two and three and four four' + >>> _lazy_images("No images here") # doctest: +ALLOW_UNICODE + 'No images here' + """ + if placeholder is None: + # 1px transparent gif + placeholder = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" + + def callback(img_tag): + match = _image_src_re.search(img_tag) + if match is not None: + src = match.group("src") + quote = match.group("quote") + quoted_src = quote + src + quote + img_tag = img_tag.replace( + match.group(0), 'src="{}" data-src={}'.format(placeholder, quoted_src) + ) + return img_tag + + return _replace_images(text, callback) - from HTMLParser import HTMLParser - class TagStripper(HTMLParser): +def _strip_tags(text): + """ + >>> _strip_tags("Hello world<img src='foo.jpg'>") # doctest: +ALLOW_UNICODE + "Hello world<img src='foo.jpg'>" + >>> _strip_tags("> > Foo") # doctest: +ALLOW_UNICODE + '> > Foo' + """ + try: + # noinspection PyCompatibility + from html.parser import HTMLParser + except ImportError: + # noinspection PyCompatibility + from HTMLParser import HTMLParser - def __init__(self): - HTMLParser.__init__(self) - self._fed = [] + class TagStripper(HTMLParser): + def __init__(self, **kw): + HTMLParser.__init__(self, **kw) + self._fed = [] - def handle_data(self, data): - self._fed.append(data) + def handle_data(self, data): + self._fed.append(data) - def handle_entityref(self, ref): - self._fed.append("&{};".format(ref)) + def handle_entityref(self, ref): + self._fed.append("&{};".format(ref)) - def handle_charref(self, ref): - self._fed.append("&#{};".format(ref)) + def handle_charref(self, ref): + self._fed.append("&#{};".format(ref)) - def get_data(self): - return "".join(self._fed) + def get_data(self): + return "".join(self._fed) - tag_stripper = TagStripper() - tag_stripper.feed(text) - return tag_stripper.get_data() + tag_stripper = TagStripper() if PY2 else TagStripper(convert_charrefs=False) + tag_stripper.feed(text) + return tag_stripper.get_data() __plugin_name__ = "Announcement Plugin" __plugin_author__ = "Gina Häußge" __plugin_description__ = "Announcements all around OctoPrint" -__plugin_disabling_discouraged__ = gettext("Without this plugin you might miss important announcements " - "regarding security or other critical issues concerning OctoPrint.") +__plugin_disabling_discouraged__ = gettext( + "Without this plugin you might miss important announcements " + "regarding security or other critical issues concerning OctoPrint." +) __plugin_license__ = "AGPLv3" +__plugin_pythoncompat__ = ">=2.7,<4" __plugin_implementation__ = AnnouncementPlugin() + +__plugin_hooks__ = { + "octoprint.access.permissions": __plugin_implementation__.get_additional_permissions +} diff --git a/src/octoprint/plugins/announcements/static/js/announcements.js b/src/octoprint/plugins/announcements/static/js/announcements.js index b2e40b5389..6eb99a8061 100644 --- a/src/octoprint/plugins/announcements/static/js/announcements.js +++ b/src/octoprint/plugins/announcements/static/js/announcements.js @@ -1,29 +1,29 @@ -$(function() { +$(function () { function AnnouncementsViewModel(parameters) { var self = this; self.loginState = parameters[0]; self.settings = parameters[1]; + self.access = parameters[2]; self.channels = new ItemListHelper( "plugin.announcements.channels", { - "channel": function (a, b) { + channel: function (a, b) { // sorts ascending if (a["channel"].toLocaleLowerCase() < b["channel"].toLocaleLowerCase()) return -1; if (a["channel"].toLocaleLowerCase() > b["channel"].toLocaleLowerCase()) return 1; return 0; } }, - { - }, + {}, "name", [], [], 5 ); - self.unread = ko.observable(); + self.unread = ko.observable(false); self.hiddenChannels = []; self.channelNotifications = {}; @@ -31,30 +31,34 @@ $(function() { self.announcementDialogContent = undefined; self.announcementDialogTabs = undefined; - self.setupTabLink = function(item) { + self.setupTabLink = function (item) { $("a[data-toggle='tab']", item).on("show", self.resetContentScroll); }; - self.resetContentScroll = function() { + self.resetContentScroll = function () { self.announcementDialogContent.scrollTop(0); }; - self.toggleButtonCss = function(data) { + self.toggleButtonCss = function (data) { var icon = data.enabled ? "fa fa-toggle-on" : "fa fa-toggle-off"; - var disabled = (self.enableToggle(data)) ? "" : " disabled"; + var disabled = self.enableToggle(data) ? "" : " disabled"; return icon + disabled; }; - self.toggleButtonTitle = function(data) { - return data.forced ? gettext("Cannot be toggled") : (data.enabled ? gettext("Disable Channel") : gettext("Enable Channel")); + self.toggleButtonTitle = function (data) { + return data.forced + ? gettext("Cannot be toggled") + : data.enabled + ? gettext("Disable Channel") + : gettext("Enable Channel"); }; - self.enableToggle = function(data) { + self.enableToggle = function (data) { return !data.forced; }; - self.cleanedLink = function(data) { + self.cleanedLink = function (data) { // Strips any query parameters from the link and returns it var link = data.link; if (!link) return link; @@ -66,8 +70,10 @@ $(function() { return link; }; - self.markRead = function(channel, until) { - if (!self.loginState.isAdmin()) return; + self.markRead = function (channel, until, reload) { + if (!self.loginState.hasPermission(self.access.permissions.PLUGIN_ANNOUNCEMENTS_READ)) return; + + reload = !!reload; var url = PLUGIN_BASEURL + "announcements/channels/" + channel; @@ -82,14 +88,16 @@ $(function() { dataType: "json", data: JSON.stringify(payload), contentType: "application/json; charset=UTF-8", - success: function() { - self.retrieveData() + success: function () { + if (reload) { + self.retrieveData(); + } } - }) + }); }; - self.toggleChannel = function(channel) { - if (!self.loginState.isAdmin()) return; + self.toggleChannel = function (channel) { + if (!self.loginState.hasPermission(self.access.permissions.PLUGIN_ANNOUNCEMENTS_MANAGE)) return; var url = PLUGIN_BASEURL + "announcements/channels/" + channel; @@ -103,18 +111,18 @@ $(function() { dataType: "json", data: JSON.stringify(payload), contentType: "application/json; charset=UTF-8", - success: function() { - self.retrieveData() + success: function () { + self.retrieveData(); } - }) + }); }; - self.refreshAnnouncements = function() { + self.refreshAnnouncements = function () { self.retrieveData(true); }; - self.retrieveData = function(force) { - if (!self.loginState.isAdmin()) return; + self.retrieveData = function (force) { + if (!self.loginState.hasPermission(self.access.permissions.PLUGIN_ANNOUNCEMENTS_READ)) return; var url = PLUGIN_BASEURL + "announcements/channels"; if (force) { @@ -125,20 +133,20 @@ $(function() { url: url, type: "GET", dataType: "json", - success: function(data) { + success: function (data) { self.fromResponse(data); } }); }; - self.fromResponse = function(data) { - if (!self.loginState.isAdmin()) return; + self.fromResponse = function (data) { + if (!self.loginState.hasPermission(self.access.permissions.PLUGIN_ANNOUNCEMENTS_READ)) return; var currentTab = $("li.active a", self.announcementDialogTabs).attr("href"); var unread = 0; var channels = []; - _.each(data.channels, function(value) { + _.each(data.channels, function (value) { value.last = value.data.length ? value.data[0].published : undefined; value.count = value.data.length; unread += value.unread; @@ -152,8 +160,8 @@ $(function() { self.selectTab(currentTab); }; - self.showAnnouncementDialog = function(channel) { - if (!self.loginState.isAdmin()) return; + self.showAnnouncementDialog = function (channel) { + if (!self.loginState.hasPermission(self.access.permissions.PLUGIN_ANNOUNCEMENTS_READ)) return; // lazy load images that still need lazy-loading $("#plugin_announcements_dialog_content article img").lazyload(); @@ -161,12 +169,17 @@ $(function() { self.announcementDialogContent.scrollTop(0); if (!self.announcementDialog.hasClass("in")) { - self.announcementDialog.modal({ - minHeight: function() { return Math.max($.fn.modal.defaults.maxHeight() - 80, 250); } - }).css({ - width: 'auto', - 'margin-left': function() { return -($(this).width() /2); } - }); + self.announcementDialog + .modal({ + minHeight: function () { + return Math.max($.fn.modal.defaults.maxHeight() - 80, 250); + } + }) + .css({ + "margin-left": function () { + return -($(this).width() / 2); + } + }); } var tab = undefined; @@ -178,24 +191,24 @@ $(function() { return false; }; - self.selectTab = function(tab) { + self.selectTab = function (tab) { if (tab != undefined) { if (!_.startsWith(tab, "#")) { tab = "#" + tab; } $('a[href="' + tab + '"]', self.announcementDialogTabs).tab("show"); } else { - $('a:first', self.announcementDialogTabs).tab("show"); + $("a:first", self.announcementDialogTabs).tab("show"); } }; - self.displayAnnouncements = function(channels) { - if (!self.loginState.isAdmin()) return; + self.displayAnnouncements = function (channels) { + if (!self.loginState.hasPermission(self.access.permissions.PLUGIN_ANNOUNCEMENTS_READ)) return; var displayLimit = self.settings.settings.plugins.announcements.display_limit(); var maxLength = self.settings.settings.plugins.announcements.summary_limit(); - var cutAfterNewline = function(text) { + var cutAfterNewline = function (text) { text = text.trim(); var firstNewlinePos = text.indexOf("\n"); @@ -206,7 +219,7 @@ $(function() { return text; }; - var stripParagraphs = function(text) { + var stripParagraphs = function (text) { if (_.startsWith(text, "

")) { text = text.substr("

".length); } @@ -214,10 +227,10 @@ $(function() { text = text.substr(0, text.length - "

".length); } - return text.replace(/<\/p>\s*

/ig, "
"); + return text.replace(/<\/p>\s*

/gi, "
"); }; - _.each(channels, function(value) { + _.each(channels, function (value) { var key = value.key; var channel = value.channel; var priority = value.priority; @@ -228,7 +241,9 @@ $(function() { return; } - var newItems = _.filter(items, function(entry) { return !entry.read; }); + var newItems = _.filter(items, function (entry) { + return !entry.read; + }); if (newItems.length == 0) { // no new items at all, we don't display anything for this channel return; @@ -243,23 +258,38 @@ $(function() { var rest = newItems.length - displayedItems.length; var text = "

    "; - _.each(displayedItems, function(item) { + _.each(displayedItems, function (item) { var limitedSummary = stripParagraphs(item.summary_without_images.trim()); if (limitedSummary.length > maxLength) { limitedSummary = limitedSummary.substr(0, maxLength); - limitedSummary = limitedSummary.substr(0, Math.min(limitedSummary.length, limitedSummary.lastIndexOf(" "))); + limitedSummary = limitedSummary.substr( + 0, + Math.min(limitedSummary.length, limitedSummary.lastIndexOf(" ")) + ); limitedSummary += "..."; } - text += "
  • " + cutAfterNewline(item.title) + "
    " + formatTimeAgo(item.published) + "

    " + limitedSummary + "

  • "; + text += + "
  • " + + cutAfterNewline(item.title) + + "
    " + + formatTimeAgo(item.published) + + "

    " + + limitedSummary + + "

  • "; }); text += "
"; if (rest) { - text += gettext(_.sprintf("... and %(rest)d more.", {rest: rest})); + text += "

" + gettext(_.sprintf("... and %(rest)d more.", {rest: rest})) + "

"; } - text += "" + gettext("You can edit your announcement subscriptions under Settings > Announcements.") + ""; + text += + "" + + gettext("You can edit your announcement subscriptions under Settings > Announcements.") + + ""; var options = { title: channel, @@ -267,27 +297,31 @@ $(function() { hide: false, confirm: { confirm: true, - buttons: [{ - text: gettext("Later"), - click: function(notice) { - notice.remove(); - self.hiddenChannels.push(key); - } - }, { - text: gettext("Mark read"), - click: function(notice) { - notice.remove(); - self.markRead(key, value.last); + buttons: [ + { + text: gettext("Later"), + click: function (notice) { + notice.remove(); + self.hiddenChannels.push(key); + } + }, + { + text: gettext("Mark read"), + click: function (notice) { + notice.remove(); + self.markRead(key, value.last); + } + }, + { + text: gettext("Read..."), + addClass: "btn-primary", + click: function (notice) { + notice.remove(); + self.showAnnouncementDialog(key); + self.markRead(key, value.last); + } } - }, { - text: gettext("Read..."), - addClass: "btn-primary", - click: function(notice) { - notice.remove(); - self.showAnnouncementDialog(key); - self.markRead(key, value.last); - } - }] + ] }, buttons: { sticker: false, @@ -306,41 +340,40 @@ $(function() { }); }; - self.hideAnnouncements = function() { - _.each(self.channelNotifications, function(notification, key) { + self.hideAnnouncements = function () { + _.each(self.channelNotifications, function (notification, key) { notification.remove(); }); self.channelNotifications = {}; }; - self.configureAnnouncements = function() { + self.configureAnnouncements = function () { self.settings.show("settings_plugin_announcements"); }; - self.onUserLoggedIn = function() { - self.retrieveData(); - }; - - self.onUserLoggedOut = function() { - self.hideAnnouncements(); + self.onUserPermissionsChanged = self.onUserLoggedIn = self.onUserLoggedOut = function () { + if (!self.loginState.hasPermission(self.access.permissions.PLUGIN_ANNOUNCEMENTS_READ)) { + self.hideAnnouncements(); + } else { + self.retrieveData(); + } }; - self.onStartup = function() { + self.onStartup = function () { self.announcementDialog = $("#plugin_announcements_dialog"); self.announcementDialogContent = $("#plugin_announcements_dialog_content"); self.announcementDialogTabs = $("#plugin_announcements_dialog_tabs"); }; - self.onEventConnectivityChanged = function(payload) { + self.onEventConnectivityChanged = function (payload) { if (!payload || !payload.new) return; self.retrieveData(); - } - + }; } OCTOPRINT_VIEWMODELS.push({ construct: AnnouncementsViewModel, - dependencies: ["loginStateViewModel", "settingsViewModel"], + dependencies: ["loginStateViewModel", "settingsViewModel", "accessViewModel"], elements: ["#plugin_announcements_dialog", "#settings_plugin_announcements", "#navbar_plugin_announcements"] }); }); diff --git a/src/octoprint/plugins/announcements/static/less/announcements.less b/src/octoprint/plugins/announcements/static/less/announcements.less index 9e90b346c7..42ffeb5f13 100644 --- a/src/octoprint/plugins/announcements/static/less/announcements.less +++ b/src/octoprint/plugins/announcements/static/less/announcements.less @@ -1,5 +1,6 @@ table { - th, td { + th, + td { &.settings_plugin_announcements_channels_name { text-overflow: ellipsis; text-align: left; diff --git a/src/octoprint/plugins/announcements/templates/announcements.jinja2 b/src/octoprint/plugins/announcements/templates/announcements.jinja2 index 524e8850a9..d1744996fe 100644 --- a/src/octoprint/plugins/announcements/templates/announcements.jinja2 +++ b/src/octoprint/plugins/announcements/templates/announcements.jinja2 @@ -1,4 +1,4 @@ - diff --git a/src/octoprint/plugins/announcements/templates/announcements_navbar.jinja2 b/src/octoprint/plugins/announcements/templates/announcements_navbar.jinja2 index 933cc55215..ac2d7f6966 100644 --- a/src/octoprint/plugins/announcements/templates/announcements_navbar.jinja2 +++ b/src/octoprint/plugins/announcements/templates/announcements_navbar.jinja2 @@ -1,3 +1,3 @@ - - + + diff --git a/src/octoprint/plugins/announcements/templates/announcements_settings.jinja2 b/src/octoprint/plugins/announcements/templates/announcements_settings.jinja2 index b3d42996dc..cda2596aef 100644 --- a/src/octoprint/plugins/announcements/templates/announcements_settings.jinja2 +++ b/src/octoprint/plugins/announcements/templates/announcements_settings.jinja2 @@ -32,4 +32,4 @@ - + diff --git a/src/octoprint/plugins/appkeys/__init__.py b/src/octoprint/plugins/appkeys/__init__.py new file mode 100644 index 0000000000..52b41fad25 --- /dev/null +++ b/src/octoprint/plugins/appkeys/__init__.py @@ -0,0 +1,493 @@ +from __future__ import absolute_import, unicode_literals + +import io +import os +import threading +from collections import defaultdict + +import flask +import yaml +from flask_babel import gettext + +import octoprint.plugin +from octoprint.access import ADMIN_GROUP +from octoprint.access.permissions import Permissions +from octoprint.server import NO_CONTENT, admin_permission, current_user +from octoprint.server.util.flask import no_firstrun_access, restricted_access +from octoprint.settings import valid_boolean_trues +from octoprint.util import ResettableTimer, atomic_write, generate_api_key, monotonic_time + +CUTOFF_TIME = 10 * 60 # 10min +POLL_TIMEOUT = 5 # 5 seconds + + +class AppAlreadyExists(Exception): + pass + + +class PendingDecision(object): + def __init__(self, app_id, app_token, user_id, user_token, timeout_callback=None): + self.app_id = app_id + self.app_token = app_token + self.user_id = user_id + self.user_token = user_token + self.created = monotonic_time() + + if callable(timeout_callback): + self.poll_timeout = ResettableTimer( + POLL_TIMEOUT, timeout_callback, [user_token] + ) + self.poll_timeout.start() + + def external(self): + return { + "app_id": self.app_id, + "user_id": self.user_id, + "user_token": self.user_token, + } + + def __repr__(self): + return "PendingDecision({!r}, {!r}, {!r}, {!r}, timeout_callback=...)".format( + self.app_id, self.app_token, self.user_id, self.user_token + ) + + +class ReadyDecision(object): + def __init__(self, app_id, app_token, user_id): + self.app_id = app_id + self.app_token = app_token + self.user_id = user_id + + @classmethod + def for_pending(cls, pending, user_id): + return cls(pending.app_id, pending.app_token, user_id) + + def __repr__(self): + return "ReadyDecision({!r}, {!r}, {!r})".format( + self.app_id, self.app_token, self.user_id + ) + + +class ActiveKey(object): + def __init__(self, app_id, api_key, user_id): + self.app_id = app_id + self.api_key = api_key + self.user_id = user_id + + def external(self): + return {"app_id": self.app_id, "api_key": self.api_key, "user_id": self.user_id} + + def internal(self): + return {"app_id": self.app_id, "api_key": self.api_key} + + @classmethod + def for_internal(cls, internal, user_id): + return cls(internal["app_id"], internal["api_key"], user_id) + + def __repr__(self): + return "ActiveKey({!r}, {!r}, {!r})".format( + self.app_id, self.api_key, self.user_id + ) + + +class AppKeysPlugin( + octoprint.plugin.AssetPlugin, + octoprint.plugin.BlueprintPlugin, + octoprint.plugin.SimpleApiPlugin, + octoprint.plugin.TemplatePlugin, +): + def __init__(self): + self._pending_decisions = [] + self._pending_lock = threading.RLock() + + self._ready_decisions = [] + self._ready_lock = threading.RLock() + + self._keys = defaultdict(list) + self._keys_lock = threading.RLock() + + self._key_path = None + + def initialize(self): + self._key_path = os.path.join(self.get_plugin_data_folder(), "keys.yaml") + self._load_keys() + + # Additional permissions hook + + def get_additional_permissions(self): + return [ + { + "key": "ADMIN", + "name": "Admin access", + "description": gettext("Allows administrating all application keys"), + "roles": ["admin"], + "dangerous": True, + "default_groups": [ADMIN_GROUP], + } + ] + + ##~~ TemplatePlugin + + def get_template_configs(self): + return [ + {"type": "usersettings", "name": gettext("Application Keys")}, + {"type": "settings", "name": gettext("Application Keys")}, + ] + + ##~~ AssetPlugin + + def get_assets(self): + return { + "js": ["js/appkeys.js"], + "clientjs": ["clientjs/appkeys.js"], + "less": ["less/appkeys.less"], + "css": ["css/appkeys.css"], + } + + ##~~ BlueprintPlugin mixin + + @octoprint.plugin.BlueprintPlugin.route("/probe", methods=["GET"]) + @no_firstrun_access + def handle_probe(self): + return NO_CONTENT + + @octoprint.plugin.BlueprintPlugin.route("/request", methods=["POST"]) + @no_firstrun_access + def handle_request(self): + data = flask.request.json + if data is None: + flask.abort(400, description="Missing key request") + + if "app" not in data: + flask.abort(400, description="No app name provided") + + app_name = data["app"] + user_id = None + if "user" in data and data["user"]: + user_id = data["user"] + + app_token, user_token = self._add_pending_decision(app_name, user_id=user_id) + + self._plugin_manager.send_plugin_message( + self._identifier, + { + "type": "request_access", + "app_name": app_name, + "user_token": user_token, + "user_id": user_id, + }, + ) + response = flask.jsonify(app_token=app_token) + response.status_code = 201 + response.headers["Location"] = flask.url_for( + ".handle_decision_poll", app_token=app_token, _external=True + ) + return response + + @octoprint.plugin.BlueprintPlugin.route("/request/") + @no_firstrun_access + def handle_decision_poll(self, app_token): + result = self._get_pending_by_app_token(app_token) + if result: + for pending_decision in result: + pending_decision.poll_timeout.reset() + + response = flask.jsonify(message="Awaiting decision") + response.status_code = 202 + return response + + result = self._get_decision(app_token) + if result: + return flask.jsonify(api_key=result) + + return flask.abort(404) + + @octoprint.plugin.BlueprintPlugin.route("/decision/", methods=["POST"]) + @restricted_access + def handle_decision(self, user_token): + data = flask.request.json + if "decision" not in data: + flask.abort(400, description="No decision provided") + decision = data["decision"] in valid_boolean_trues + user_id = current_user.get_name() + + result = self._set_decision(user_token, decision, user_id) + if not result: + return flask.abort(404) + + # Close access_request dialog for this request on all open OctoPrint connections + self._plugin_manager.send_plugin_message( + self._identifier, {"type": "end_request", "user_token": user_token} + ) + + return NO_CONTENT + + def is_blueprint_protected(self): + return False # No API key required to request API access + + ##~~ SimpleApiPlugin mixin + + def get_api_commands(self): + return {"generate": ["app"], "revoke": ["key"]} + + def on_api_get(self, request): + user_id = current_user.get_name() + if not user_id: + return flask.abort(403) + + if ( + request.values.get("all") in valid_boolean_trues + and Permissions.PLUGIN_APPKEYS_ADMIN.can() + ): + keys = self._all_api_keys() + else: + keys = self._api_keys_for_user(user_id) + + return flask.jsonify( + keys=list(map(lambda x: x.external(), keys)), + pending={ + x.user_token: x.external() for x in self._get_pending_by_user_id(user_id) + }, + ) + + def on_api_command(self, command, data): + user_id = current_user.get_name() + if not user_id: + return flask.abort(403) + + if command == "revoke": + api_key = data.get("key") + if not api_key: + return flask.abort(400) + + if not admin_permission.can(): + user_for_key = self._user_for_api_key(api_key) + if user_for_key is None or user_for_key.user_id != user_id: + return flask.abort(403) + + self._delete_api_key(api_key) + + elif command == "generate": + # manual generateKey + app_name = data.get("app") + if not app_name: + return flask.abort(400) + + selected_user_id = data.get("user", user_id) + if selected_user_id != user_id and not Permissions.PLUGIN_APPKEYS_ADMIN.can(): + return flask.abort(403) + + key = self._add_api_key(selected_user_id, app_name.strip()) + return flask.jsonify(user_id=selected_user_id, app_id=app_name, api_key=key) + + return NO_CONTENT + + ##~~ key validator hook + + def validate_api_key(self, api_key, *args, **kwargs): + return self._user_for_api_key(api_key) + + ##~~ Helpers + + def _add_pending_decision(self, app_name, user_id=None): + app_token = self._generate_key() + user_token = self._generate_key() + + with self._pending_lock: + self._remove_stale_pending() + self._pending_decisions.append( + PendingDecision( + app_name, + app_token, + user_id, + user_token, + timeout_callback=self._expire_pending, + ) + ) + + return app_token, user_token + + def _get_pending_by_app_token(self, app_token): + result = [] + with self._pending_lock: + self._remove_stale_pending() + for data in self._pending_decisions: + if data.app_token == app_token: + result.append(data) + return result + + def _get_pending_by_user_id(self, user_id): + result = [] + with self._pending_lock: + self._remove_stale_pending() + for data in self._pending_decisions: + if data.user_id == user_id or data.user_id is None: + result.append(data) + return result + + def _expire_pending(self, user_token): + with self._pending_lock: + len_before = len(self._pending_decisions) + self._pending_decisions = list( + filter(lambda x: x.user_token != user_token, self._pending_decisions) + ) + len_after = len(self._pending_decisions) + + if len_after < len_before: + self._plugin_manager.send_plugin_message( + self._identifier, {"type": "end_request", "user_token": user_token} + ) + + def _remove_stale_pending(self): + with self._pending_lock: + cutoff = monotonic_time() - CUTOFF_TIME + len_before = len(self._pending_decisions) + self._pending_decisions = list( + filter(lambda x: x.created >= cutoff, self._pending_decisions) + ) + len_after = len(self._pending_decisions) + if len_after < len_before: + self._logger.info( + "Deleted {} stale pending authorization requests".format( + len_before - len_after + ) + ) + + def _set_decision(self, user_token, decision, user_id): + with self._pending_lock: + self._remove_stale_pending() + for data in self._pending_decisions: + if data.user_token == user_token and ( + data.user_id == user_id or data.user_id is None + ): + pending = data + break + else: + return False # not found + + if decision: + with self._ready_lock: + self._ready_decisions.append(ReadyDecision.for_pending(pending, user_id)) + + with self._pending_lock: + self._pending_decisions = list( + filter(lambda x: x.user_token != user_token, self._pending_decisions) + ) + + return True + + def _get_decision(self, app_token): + self._remove_stale_pending() + + with self._ready_lock: + for data in self._ready_decisions: + if data.app_token == app_token: + decision = data + break + else: + return False # not found + + api_key = self._add_api_key(decision.user_id, decision.app_id) + + with self._ready_lock: + self._ready_decisions = list( + filter(lambda x: x.app_token != app_token, self._ready_decisions) + ) + + return api_key + + def _add_api_key(self, user_id, app_name): + with self._keys_lock: + for key in self._keys[user_id]: + if key.app_id.lower() == app_name.lower(): + return key.api_key + + key = ActiveKey(app_name, self._generate_key(), user_id) + self._keys[user_id].append(key) + self._save_keys() + return key.api_key + + def _delete_api_key(self, api_key): + with self._keys_lock: + for user_id, data in self._keys.items(): + self._keys[user_id] = list(filter(lambda x: x.api_key != api_key, data)) + self._save_keys() + + def _user_for_api_key(self, api_key): + with self._keys_lock: + for user_id, data in self._keys.items(): + if any(filter(lambda x: x.api_key == api_key, data)): + return self._user_manager.find_user(userid=user_id) + return None + + def _api_keys_for_user(self, user_id): + with self._keys_lock: + return self._keys[user_id] + + def _all_api_keys(self): + with self._keys_lock: + result = [] + for keys in self._keys.values(): + result += keys + return result + + def _generate_key(self): + return generate_api_key() + + def _load_keys(self): + with self._keys_lock: + if not os.path.exists(self._key_path): + return + + try: + with io.open( + self._key_path, "rt", encoding="utf-8", errors="strict" + ) as f: + persisted = yaml.safe_load(f) + except Exception: + self._logger.exception( + "Could not load application keys from {}".format(self._key_path) + ) + return + + if not isinstance(persisted, dict): + return + + keys = defaultdict(list) + for user_id, persisted_keys in persisted.items(): + keys[user_id] = [ + ActiveKey.for_internal(x, user_id) for x in persisted_keys + ] + self._keys = keys + + def _save_keys(self): + with self._keys_lock: + to_persist = {} + for user_id, keys in self._keys.items(): + to_persist[user_id] = [x.internal() for x in keys] + + try: + with atomic_write(self._key_path, mode="wt") as f: + yaml.safe_dump(to_persist, f, allow_unicode=True) + except Exception: + self._logger.exception( + "Could not write application keys to {}".format(self._key_path) + ) + + +__plugin_name__ = "Application Keys Plugin" +__plugin_description__ = ( + "Implements a workflow for third party clients to obtain API keys" +) +__plugin_author__ = "Gina Häußge, Aldo Hoeben" +__plugin_disabling_discouraged__ = gettext( + "Without this plugin third party clients will no longer be able to " + "obtain an API key without you manually copy-pasting it." +) +__plugin_license__ = "AGPLv3" +__plugin_pythoncompat__ = ">=2.7,<4" +__plugin_implementation__ = AppKeysPlugin() +__plugin_hooks__ = { + "octoprint.accesscontrol.keyvalidator": __plugin_implementation__.validate_api_key, + "octoprint.access.permissions": __plugin_implementation__.get_additional_permissions, +} diff --git a/src/octoprint/plugins/appkeys/static/clientjs/appkeys.js b/src/octoprint/plugins/appkeys/static/clientjs/appkeys.js new file mode 100644 index 0000000000..5804d2d2b9 --- /dev/null +++ b/src/octoprint/plugins/appkeys/static/clientjs/appkeys.js @@ -0,0 +1,109 @@ +(function (global, factory) { + if (typeof define === "function" && define.amd) { + define(["OctoPrintClient"], factory); + } else { + factory(global.OctoPrintClient); + } +})(this, function (OctoPrintClient) { + var OctoPrintAppKeysClient = function (base) { + this.base = base; + }; + + OctoPrintAppKeysClient.prototype.getKeys = function (opts) { + return this.base.simpleApiGet("appkeys", opts); + }; + + OctoPrintAppKeysClient.prototype.getAllKeys = function (opts) { + return this.base.get(OctoPrintClient.prototype.getSimpleApiUrl("appkeys") + "?all=true", opts); + }; + + OctoPrintAppKeysClient.prototype.generateKey = function (app, opts) { + return this.base.simpleApiCommand("appkeys", "generate", {app: app}, opts); + }; + + OctoPrintAppKeysClient.prototype.generateKeyForUser = function (user, app, opts) { + return this.base.simpleApiCommand("appkeys", "generate", {app: app, user: user}, opts); + }; + + OctoPrintAppKeysClient.prototype.revokeKey = function (key, opts) { + return this.base.simpleApiCommand("appkeys", "revoke", {key: key}, opts); + }; + + OctoPrintAppKeysClient.prototype.decide = function (token, decision, opts) { + return this.base.postJson( + this.base.getBlueprintUrl("appkeys") + "decision/" + token, + {decision: !!decision}, + opts + ); + }; + + OctoPrintAppKeysClient.prototype.probe = function (opts) { + return this.base.get(this.base.getBlueprintUrl("appkeys") + "probe", opts); + }; + + OctoPrintAppKeysClient.prototype.request = function (app, opts) { + return this.requestForUser(app, undefined, opts); + }; + + OctoPrintAppKeysClient.prototype.requestForUser = function (app, user, opts) { + return this.base.postJson(this.base.getBlueprintUrl("appkeys") + "request", {app: app, user: user}, opts); + }; + + OctoPrintAppKeysClient.prototype.checkDecision = function (token, opts) { + return this.base.get(this.base.getBlueprintUrl("appkeys") + "request/" + token, opts); + }; + + OctoPrintAppKeysClient.prototype.authenticate = function (app, user) { + var deferred = $.Deferred(); + var client = this; + + client + .probe() + .done(function () { + client + .requestForUser(app, user) + .done(function (response) { + var token = response.app_token; + if (!token) { + // no token received, something went wrong + deferred.reject(); + return; + } + + var interval = 1000; + var poll = function () { + client + .checkDecision(token) + .done(function (response) { + if (response.api_key) { + // got a decision, resolve the promise + deferred.resolve(response.api_key); + } else { + // no decision yet, poll a bit more + deferred.notify(); + window.setTimeout(poll, interval); + } + }) + .fail(function () { + // something went wrong + deferred.reject(); + }); + }; + window.setTimeout(poll, interval); + }) + .fail(function () { + // something went wrong + deferred.reject(); + }); + }) + .fail(function () { + // workflow unsupported + deferred.reject(); + }); + + return deferred.promise(); + }; + + OctoPrintClient.registerPluginComponent("appkeys", OctoPrintAppKeysClient); + return OctoPrintAppKeysClient; +}); diff --git a/src/octoprint/plugins/appkeys/static/css/appkeys.css b/src/octoprint/plugins/appkeys/static/css/appkeys.css new file mode 100644 index 0000000000..4e08280309 --- /dev/null +++ b/src/octoprint/plugins/appkeys/static/css/appkeys.css @@ -0,0 +1 @@ +#settings_plugin_appkeys_allkeys_table .settings_plugin_appkeys_actions,#settings_plugin_appkeys_userkeys_table .settings_plugin_appkeys_actions{width:75px;text-align:center}#settings_plugin_appkeys_allkeys_table .settings_plugin_appkeys_actions a,#settings_plugin_appkeys_userkeys_table .settings_plugin_appkeys_actions a{color:#000}#settings_plugin_appkeys_allkeys_table .settings_plugin_appkeys_user,#settings_plugin_appkeys_userkeys_table .settings_plugin_appkeys_user{width:100px}#settings_plugin_appkeys_allkeys_table .settings_plugin_appkeys_checkbox,#settings_plugin_appkeys_userkeys_table .settings_plugin_appkeys_checkbox{text-align:center;width:10px}#settings_plugin_appkeys_allkeys_table .settings_plugin_appkeys_checkbox input[type=checkbox],#settings_plugin_appkeys_userkeys_table .settings_plugin_appkeys_checkbox input[type=checkbox]{margin-top:0}#plugin_appkeys_keygenerated .control-text{display:inline-block} \ No newline at end of file diff --git a/src/octoprint/plugins/appkeys/static/js/appkeys.js b/src/octoprint/plugins/appkeys/static/js/appkeys.js new file mode 100644 index 0000000000..e85bad2122 --- /dev/null +++ b/src/octoprint/plugins/appkeys/static/js/appkeys.js @@ -0,0 +1,402 @@ +$(function () { + function AppKeysDialogViewModel(parameters) { + var self = this; + + self.dialog = undefined; + + self.onStartup = function () { + self.dialog = $("#plugin_appkeys_keygenerated"); + }; + + self.showDialog = function (title, data) { + if (self.dialog === undefined) return; + + var qrcode = { + text: data.api_key, + size: 180, + fill: "#000", + background: null, + label: "", + fontname: "sans", + fontcolor: "#000", + radius: 0, + ecLevel: "L" + }; + + self.dialog.find("#plugin_appkeys_keygenerated_title").text(title); + self.dialog.find("#plugin_appkeys_keygenerated_user").text(data.user_id); + self.dialog.find("#plugin_appkeys_keygenerated_app").text(data.app_id); + self.dialog.find("#plugin_appkeys_keygenerated_key_text").text(data.api_key); + self.dialog + .find("#plugin_appkeys_keygenerated_key_copy") + .off() + .click(function () { + copyToClipboard(data.api_key); + }); + self.dialog.find("#plugin_appkeys_keygenerated_key_qrcode").empty().qrcode(qrcode); + + self.dialog.modal("show"); + }; + } + + function UserAppKeysViewModel(parameters) { + var self = this; + self.dialog = parameters[0]; + self.loginState = parameters[1]; + + self.keys = new ItemListHelper( + "plugin.appkeys.userkeys", + { + app: function (a, b) { + // sorts ascending + if (a["app_id"].toLowerCase() < b["app_id"].toLowerCase()) return -1; + if (a["app_id"].toLowerCase() > b["app_id"].toLowerCase()) return 1; + return 0; + } + }, + {}, + "app", + [], + [], + 5 + ); + self.pending = {}; + self.openRequests = {}; + + self.editorApp = ko.observable(); + + self.requestData = function () { + OctoPrint.plugins.appkeys.getKeys().done(self.fromResponse); + }; + + self.fromResponse = function (response) { + self.keys.updateItems(response.keys); + self.pending = response.pending; + _.each(self.pending, function (data, token) { + self.openRequests[token] = self.promptForAccess(data.app_id, token); + }); + }; + + self.generateKey = function () { + return OctoPrint.plugins.appkeys + .generateKey(self.editorApp()) + .done(self.requestData) + .done(function () { + self.editorApp(""); + }); + }; + + self.revokeKey = function (key) { + var perform = function () { + OctoPrint.plugins.appkeys.revokeKey(key).done(self.requestData); + }; + + showConfirmationDialog( + _.sprintf(gettext('You are about to revoke the application key "%(key)s".'), {key: _.escape(key)}), + perform + ); + }; + + self.allowApp = function (token) { + return OctoPrint.plugins.appkeys.decide(token, true).done(self.requestData); + }; + + self.denyApp = function (token) { + return OctoPrint.plugins.appkeys.decide(token, false).done(self.requestData); + }; + + self.promptForAccess = function (app, token) { + var message = gettext( + '"%(app)s" has requested access to control OctoPrint through the API.' + ); + message = _.sprintf(message, {app: _.escape(app)}); + message = + "

" + + message + + "

" + + gettext("Do you want to allow access to this application with your user account?") + + "

"; + return new PNotify({ + title: gettext("Access Request"), + text: message, + hide: false, + icon: "fa fa-key", + confirm: { + confirm: true, + buttons: [ + { + text: gettext("Allow"), + click: function (notice) { + self.allowApp(token); + notice.remove(); + } + }, + { + text: gettext("Deny"), + click: function (notice) { + self.denyApp(token); + notice.remove(); + } + } + ] + }, + buttons: { + sticker: false, + closer: false + } + }); + }; + + self.onUserSettingsShown = function () { + self.requestData(); + }; + + self.onUserLoggedIn = function () { + self.requestData(); + }; + + self.onDataUpdaterPluginMessage = function (plugin, data) { + if (plugin !== "appkeys") { + return; + } + + var app, token, user; + + if (data.type === "request_access" && self.loginState.isUser()) { + app = data.app_name; + token = data.user_token; + user = data.user_id; + + if (user && user !== self.loginState.username()) { + return; + } + + if (self.pending[token] !== undefined) { + return; + } + + self.openRequests[token] = self.promptForAccess(app, token); + } else if (data.type === "end_request") { + token = data.user_token; + + if (self.openRequests[token] !== undefined) { + // another instance responded to the access request before the current user did + if (self.openRequests[token].state !== "closed") { + self.openRequests[token].remove(); + } + delete self.openRequests[token]; + } + } + }; + } + + function AllAppKeysViewModel(parameters) { + var self = this; + self.dialog = parameters[0]; + self.loginState = parameters[1]; + self.access = parameters[2]; + + self.keys = new ItemListHelper( + "plugin.appkeys.allkeys", + { + user_app: function (a, b) { + // sorts ascending, first by user, then by app + if (a["user_id"] > b["user_id"]) return 1; + if (a["user_id"] < b["user_id"]) return -1; + + if (a["app_id"].toLowerCase() > b["app_id"].toLowerCase()) return 1; + if (a["app_id"].toLowerCase() < b["app_id"].toLowerCase()) return -1; + + return 0; + } + }, + {}, + "user_app", + [], + [], + 10 + ); + self.users = ko.observableArray([]); + self.apps = ko.observableArray([]); + + self.editorApp = ko.observable(); + self.editorUser = ko.observable(); + + self.markedForDeletion = ko.observableArray([]); + + self.onSettingsShown = function () { + self.requestData(); + self.editorUser(self.loginState.username()); + self.editorApp(""); + }; + + self.onUserLoggedIn = function () { + self.requestData(); + self.editorUser(self.loginState.username()); + self.editorApp(""); + }; + + self.requestData = function () { + OctoPrint.plugins.appkeys.getAllKeys().done(self.fromResponse); + }; + + self.fromResponse = function (response) { + self.keys.updateItems(response.keys); + + var users = []; + var apps = []; + _.each(response.keys, function (key) { + users.push(key.user_id); + apps.push(key.app_id.toLowerCase()); + }); + + users = _.uniq(users); + users.sort(); + self.users(users); + + apps = _.uniq(apps); + apps.sort(); + self.apps(apps); + }; + + self.generateKey = function () { + return OctoPrint.plugins.appkeys + .generateKeyForUser(self.editorUser(), self.editorApp()) + .done(self.requestData) + .done(function () { + self.editorUser(self.loginState.username()); + self.editorApp(""); + }) + .done(function (data) { + self.dialog.showDialog(gettext("New key generated!"), data); + }); + }; + + self.revokeKey = function (key) { + var perform = function () { + OctoPrint.plugins.appkeys.revokeKey(key).done(self.requestData); + }; + + showConfirmationDialog( + _.sprintf(gettext('You are about to revoke the application key "%(key)s".'), {key: _.escape(key)}), + perform + ); + }; + + self.revokeMarked = function () { + var perform = function () { + self._bulkRevoke(self.markedForDeletion()).done(function () { + self.markedForDeletion.removeAll(); + }); + }; + + showConfirmationDialog( + _.sprintf(gettext("You are about to revoke %(count)d application keys."), { + count: self.markedForDeletion().length + }), + perform + ); + }; + + self.markAllOnPageForDeletion = function () { + self.markedForDeletion( + _.uniq(self.markedForDeletion().concat(_.map(self.keys.paginatedItems(), "api_key"))) + ); + }; + + self.markAllForDeletion = function () { + self.markedForDeletion(_.uniq(_.map(self.keys.allItems, "api_key"))); + }; + + self.markAllByUserForDeletion = function (user) { + self.markAllByFilterForDeletion(function (e) { + return e.user_id === user; + }); + }; + + self.markAllByAppForDeletion = function (app) { + self.markAllByFilterForDeletion(function (e) { + return e.app_id.toLowerCase() === app; + }); + }; + + self.markAllByFilterForDeletion = function (filter) { + self.markedForDeletion( + _.uniq(self.markedForDeletion().concat(_.map(_.filter(self.keys.allItems, filter), "api_key"))) + ); + }; + + self.clearMarked = function () { + self.markedForDeletion.removeAll(); + }; + + self._bulkRevoke = function (keys) { + var title, message, handler; + + title = gettext("Revoking application keys"); + message = _.sprintf(gettext("Revoking %(count)d application keys..."), { + count: keys.length + }); + handler = function (key) { + return OctoPrint.plugins.appkeys + .revokeKey(key) + .done(function () { + deferred.notify( + _.sprintf(gettext("Revoked %(key)s..."), { + key: _.escape(key) + }), + true + ); + }) + .fail(function (jqXHR) { + var short = _.sprintf(gettext("Revocation of %(key)s failed, continuing..."), { + key: _.escape(key) + }); + var long = _.sprintf(gettext("Deletion of %(key)s failed: %(error)s"), { + key: _.escape(key), + error: _.escape(jqXHR.responseText) + }); + deferred.notify(short, long, false); + }); + }; + + var deferred = $.Deferred(); + + var promise = deferred.promise(); + + var options = { + title: title, + message: message, + max: keys.length, + output: true + }; + showProgressModal(options, promise); + + var requests = []; + _.each(keys, function (key) { + var request = handler(key); + requests.push(request); + }); + $.when.apply($, _.map(requests, wrapPromiseWithAlways)).done(function () { + deferred.resolve(); + self.requestData(); + }); + + return promise; + }; + } + + OCTOPRINT_VIEWMODELS.push([AppKeysDialogViewModel, [], []]); + + OCTOPRINT_VIEWMODELS.push([ + UserAppKeysViewModel, + ["appKeysDialogViewModel", "loginStateViewModel"], + ["#usersettings_plugin_appkeys"] + ]); + + OCTOPRINT_VIEWMODELS.push([ + AllAppKeysViewModel, + ["appKeysDialogViewModel", "loginStateViewModel", "accessViewModel"], + ["#settings_plugin_appkeys"] + ]); +}); diff --git a/src/octoprint/plugins/appkeys/static/less/appkeys.less b/src/octoprint/plugins/appkeys/static/less/appkeys.less new file mode 100644 index 0000000000..38f95fdaaa --- /dev/null +++ b/src/octoprint/plugins/appkeys/static/less/appkeys.less @@ -0,0 +1,30 @@ +#settings_plugin_appkeys_userkeys_table, +#settings_plugin_appkeys_allkeys_table { + .settings_plugin_appkeys_app { + } + .settings_plugin_appkeys_actions { + width: 75px; + text-align: center; + + a { + color: black; + } + } + .settings_plugin_appkeys_user { + width: 100px; + } + .settings_plugin_appkeys_checkbox { + text-align: center; + width: 10px; + + input[type="checkbox"] { + margin-top: 0; + } + } +} + +#plugin_appkeys_keygenerated { + .control-text { + display: inline-block; + } +} diff --git a/src/octoprint/plugins/appkeys/templates/appkeys.jinja2 b/src/octoprint/plugins/appkeys/templates/appkeys.jinja2 new file mode 100644 index 0000000000..4b4f181414 --- /dev/null +++ b/src/octoprint/plugins/appkeys/templates/appkeys.jinja2 @@ -0,0 +1,38 @@ + diff --git a/src/octoprint/plugins/appkeys/templates/appkeys_settings.jinja2 b/src/octoprint/plugins/appkeys/templates/appkeys_settings.jinja2 new file mode 100644 index 0000000000..901b478b61 --- /dev/null +++ b/src/octoprint/plugins/appkeys/templates/appkeys_settings.jinja2 @@ -0,0 +1,82 @@ +

{{ _('Registered application keys') }}

+ +
+

{{ _('There are no application keys registered yet.') }}

+
+
+ + + + + + + + + + + + + + + + + + +
{{ _('User') }}{{ _('Application identifier') }}{{ _('Action') }}

{{ _('API Key') }}:
 | 
+ +
+

{{ _('Manually generate an application key') }}

+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
diff --git a/src/octoprint/plugins/appkeys/templates/appkeys_usersettings.jinja2 b/src/octoprint/plugins/appkeys/templates/appkeys_usersettings.jinja2 new file mode 100644 index 0000000000..5c0930efb8 --- /dev/null +++ b/src/octoprint/plugins/appkeys/templates/appkeys_usersettings.jinja2 @@ -0,0 +1,49 @@ +{{ _('Registered application keys') }} + +
+

{{ _('There are no application keys registered yet.') }}

+
+
+ + + + + + + + + + + + + +
{{ _('Application identifier') }}{{ _('Action') }}

{{ _('API Key') }}:
+ +
+ +{{ _('Manually generate an application key') }} + +
+
+ +
+ +
+
+ +
+
+ +
+
+
diff --git a/src/octoprint/plugins/backup/__init__.py b/src/octoprint/plugins/backup/__init__.py new file mode 100644 index 0000000000..222737b7a8 --- /dev/null +++ b/src/octoprint/plugins/backup/__init__.py @@ -0,0 +1,1431 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" +__copyright__ = "Copyright (C) 2018 The OctoPrint Project - Released under terms of the AGPLv3 License" + +import octoprint.plugin +from octoprint.access import ADMIN_GROUP +from octoprint.access.permissions import Permissions +from octoprint.events import Events +from octoprint.server import NO_CONTENT +from octoprint.server.util.flask import no_firstrun_access +from octoprint.settings import default_settings +from octoprint.util import is_hidden_path, to_bytes +from octoprint.util.pip import create_pip_caller +from octoprint.util.platform import is_os_compatible +from octoprint.util.version import ( + get_comparable_version, + get_octoprint_version, + get_octoprint_version_string, + is_octoprint_compatible, +) + +try: + from os import scandir +except ImportError: + from scandir import scandir + +try: + import zlib # check if zlib is available +except ImportError: + zlib = None + + +import io +import json +import logging +import os +import shutil +import sys +import tempfile +import threading +import time +import traceback +import zipfile + +import flask +import requests +import sarge +from flask_babel import gettext + +from octoprint.plugins.pluginmanager import DEFAULT_PLUGIN_REPOSITORY +from octoprint.settings import valid_boolean_trues +from octoprint.util import get_formatted_size +from octoprint.util.text import sanitize + +UNKNOWN_PLUGINS_FILE = "unknown_plugins_from_restore.json" + +BACKUP_DATE_TIME_FMT = "%Y%m%d-%H%M%S" + +MAX_UPLOAD_SIZE = 1024 * 1024 * 1024 # 1GB + + +class BackupPlugin( + octoprint.plugin.SettingsPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.AssetPlugin, + octoprint.plugin.BlueprintPlugin, + octoprint.plugin.StartupPlugin, + octoprint.plugin.WizardPlugin, +): + + _pip_caller = None + + # noinspection PyMissingConstructor + def __init__(self): + self._in_progress = [] + self._in_progress_lock = threading.RLock() + + # Additional permissions hook + + def get_additional_permissions(self): + return [ + { + "key": "ACCESS", + "name": "Backup access", + "description": gettext("Allows access to backups and restores"), + "roles": ["access"], + "dangerous": True, + "default_groups": [ADMIN_GROUP], + } + ] + + # Socket emit hook + + def socket_emit_hook(self, socket, user, message, payload, *args, **kwargs): + if message != "event" or payload["type"] != Events.PLUGIN_BACKUP_BACKUP_CREATED: + return True + + return user and user.has_permission(Permissions.PLUGIN_BACKUP_ACCESS) + + ##~~ StartupPlugin + + def on_after_startup(self): + self._clean_dir_backup(self._settings._basedir, on_log_progress=self._logger.info) + + ##~~ SettingsPlugin + + def get_settings_defaults(self): + return {"restore_unsupported": False} + + ##~~ AssetPlugin + + def get_assets(self): + return { + "js": ["js/backup.js"], + "clientjs": ["clientjs/backup.js"], + "css": ["css/backup.css"], + "less": ["less/backup.less"], + } + + ##~~ TemplatePlugin + + def get_template_configs(self): + return [ + {"type": "settings", "name": gettext("Backup & Restore")}, + {"type": "wizard", "name": gettext("Restore Backup?")}, + ] + + def get_template_vars(self): + return { + "max_upload_size": MAX_UPLOAD_SIZE, + "max_upload_size_str": get_formatted_size(MAX_UPLOAD_SIZE), + } + + ##~~ BlueprintPlugin + + @octoprint.plugin.BlueprintPlugin.route("/", methods=["GET"]) + @no_firstrun_access + @Permissions.PLUGIN_BACKUP_ACCESS.require(403) + def get_state(self): + backups = self._get_backups() + unknown_plugins = self._get_unknown_plugins() + return flask.jsonify( + backups=backups, + backup_in_progress=len(self._in_progress) > 0, + unknown_plugins=unknown_plugins, + restore_supported=self._restore_supported(self._settings), + max_upload_size=MAX_UPLOAD_SIZE, + ) + + @octoprint.plugin.BlueprintPlugin.route("/unknown_plugins", methods=["GET"]) + @no_firstrun_access + @Permissions.PLUGIN_BACKUP_ACCESS.require(403) + def get_unknown_plugins(self): + # TODO add caching + unknown_plugins = self._get_unknown_plugins() + return flask.jsonify(unknown_plugins=unknown_plugins) + + @octoprint.plugin.BlueprintPlugin.route("/unknown_plugins", methods=["DELETE"]) + @no_firstrun_access + @Permissions.PLUGIN_BACKUP_ACCESS.require(403) + def delete_unknown_plugins(self): + data_file = os.path.join(self.get_plugin_data_folder(), UNKNOWN_PLUGINS_FILE) + try: + os.remove(data_file) + except Exception: + pass + return NO_CONTENT + + @octoprint.plugin.BlueprintPlugin.route("/backup", methods=["GET"]) + @no_firstrun_access + @Permissions.PLUGIN_BACKUP_ACCESS.require(403) + def get_backups(self): + backups = self._get_backups() + return flask.jsonify(backups=backups) + + @octoprint.plugin.BlueprintPlugin.route("/backup", methods=["POST"]) + @no_firstrun_access + @Permissions.PLUGIN_BACKUP_ACCESS.require(403) + def create_backup(self): + + data = flask.request.json + exclude = data.get("exclude", []) + filename = self._build_backup_filename(settings=self._settings) + + self._start_backup(exclude, filename) + + response = flask.jsonify(started=True, name=filename) + response.status_code = 201 + return response + + @octoprint.plugin.BlueprintPlugin.route("/backup/", methods=["DELETE"]) + @no_firstrun_access + @Permissions.PLUGIN_BACKUP_ACCESS.require(403) + def delete_backup(self, filename): + self._delete_backup(filename) + + return NO_CONTENT + + @octoprint.plugin.BlueprintPlugin.route("/restore", methods=["POST"]) + def perform_restore(self): + if not Permissions.PLUGIN_BACKUP_ACCESS.can() and not self._settings.global_get( + ["server", "firstRun"] + ): + flask.abort(403) + + if not self._restore_supported(self._settings): + flask.abort( + 400, + description="Invalid request, the restores are not " + "supported on the underlying operating system", + ) + + input_name = "file" + input_upload_path = ( + input_name + + "." + + self._settings.global_get(["server", "uploads", "pathSuffix"]) + ) + + if input_upload_path in flask.request.values: + # file to restore was uploaded + path = flask.request.values[input_upload_path] + + elif flask.request.json and "path" in flask.request.json: + # existing backup is supposed to be restored + backup_folder = self.get_plugin_data_folder() + path = os.path.realpath( + os.path.join(backup_folder, flask.request.json["path"]) + ) + if ( + not path.startswith(backup_folder) + or not os.path.exists(path) + or is_hidden_path(path) + ): + return flask.abort(404) + + else: + flask.abort( + 400, + description="Invalid request, neither a file nor a path of a file to " + "restore provided", + ) + + def on_install_plugins(plugins): + force_user = self._settings.global_get_boolean( + ["plugins", "pluginmanager", "pip_force_user"] + ) + pip_args = self._settings.global_get(["plugins", "pluginmanager", "pip_args"]) + + def on_log(line): + self._logger.info(line) + self._send_client_message("logline", {"line": line, "type": "stdout"}) + + for plugin in plugins: + octoprint_compatible = is_octoprint_compatible( + *plugin["compatibility"]["octoprint"] + ) + os_compatible = is_os_compatible(plugin["compatibility"]["os"]) + compatible = octoprint_compatible and os_compatible + if not compatible: + if not octoprint_compatible and not os_compatible: + self._logger.warning( + "Cannot install plugin {}, it is incompatible to this version " + "of OctoPrint and the underlying operating system".format( + plugin["id"] + ) + ) + elif not octoprint_compatible: + self._logger.warning( + "Cannot install plugin {}, it is incompatible to this version " + "of OctoPrint".format(plugin["id"]) + ) + elif not os_compatible: + self._logger.warning( + "Cannot install plugin {}, it is incompatible to the underlying " + "operating system".format(plugin["id"]) + ) + self._send_client_message( + "plugin_incompatible", + { + "plugin": plugin["id"], + "octoprint_compatible": octoprint_compatible, + "os_compatible": os_compatible, + }, + ) + continue + + self._logger.info("Installing plugin {}".format(plugin["id"])) + self._send_client_message("installing_plugin", {"plugin": plugin["id"]}) + self.__class__._install_plugin( + plugin, + force_user=force_user, + pip_command=self._settings.global_get( + ["server", "commands", "localPipCommand"] + ), + pip_args=pip_args, + on_log=on_log, + ) + + def on_report_unknown_plugins(plugins): + self._send_client_message("unknown_plugins", payload={"plugins": plugins}) + + def on_log_progress(line): + self._logger.info(line) + self._send_client_message( + "logline", payload={"line": line, "stream": "stdout"} + ) + + def on_log_error(line, exc_info=None): + self._logger.error(line, exc_info=exc_info) + self._send_client_message( + "logline", payload={"line": line, "stream": "stderr"} + ) + + if exc_info is not None: + exc_type, exc_value, exc_tb = exc_info + output = traceback.format_exception(exc_type, exc_value, exc_tb) + for line in output: + self._send_client_message( + "logline", payload={"line": line.rstrip(), "stream": "stderr"} + ) + + def on_restore_start(path): + self._send_client_message("restore_started") + + def on_restore_done(path): + self._send_client_message("restore_done") + + def on_restore_failed(path): + self._send_client_message("restore_failed") + + def on_invalid_backup(line): + on_log_error(line) + + archive = tempfile.NamedTemporaryFile(delete=False) + archive.close() + shutil.copy(path, archive.name) + path = archive.name + + # noinspection PyTypeChecker + thread = threading.Thread( + target=self._restore_backup, + args=(path,), + kwargs={ + "settings": self._settings, + "plugin_manager": self._plugin_manager, + "datafolder": self.get_plugin_data_folder(), + "on_install_plugins": on_install_plugins, + "on_report_unknown_plugins": on_report_unknown_plugins, + "on_invalid_backup": on_invalid_backup, + "on_log_progress": on_log_progress, + "on_log_error": on_log_error, + "on_restore_start": on_restore_start, + "on_restore_done": on_restore_done, + "on_restore_failed": on_restore_failed, + }, + ) + thread.daemon = True + thread.start() + + return flask.jsonify(started=True) + + def is_blueprint_protected(self): + return False + + ##~~ WizardPlugin + + def is_wizard_required(self): + return self._settings.global_get(["server", "firstRun"]) and is_os_compatible( + ["!windows"] + ) + + def get_wizard_details(self): + return {"required": self.is_wizard_required()} + + ##~~ tornado hook + + def route_hook(self, *args, **kwargs): + from octoprint.server import app + from octoprint.server.util.flask import admin_validator + from octoprint.server.util.tornado import ( + LargeResponseHandler, + access_validation_factory, + path_validation_factory, + ) + from octoprint.util import is_hidden_path + + return [ + ( + r"/download/(.*)", + LargeResponseHandler, + { + "path": self.get_plugin_data_folder(), + "as_attachment": True, + "path_validation": path_validation_factory( + lambda path: not is_hidden_path(path), status_code=404 + ), + "access_validation": access_validation_factory(app, admin_validator), + }, + ) + ] + + def bodysize_hook(self, current_max_body_sizes, *args, **kwargs): + # max upload size for the restore endpoint + return [("POST", r"/restore", MAX_UPLOAD_SIZE)] + + # Exported plugin helpers + def create_backup_helper(self, exclude=None, filename=None): + """ + .. versionadded:: 1.6.0 + + Create a backup from a plugin or other internal call + + This helper is exported as ``create_backup`` and can be used from the plugin + manager's ``get_helpers`` method. + + **Example** + + The following code snippet can be used from within a plugin, and will create a backup + excluding two folders (``timelapse`` and ``uploads``) + + .. code-block:: python + + helpers = self._plugin_manager.get_helpers("backup", "create_backup") + + if helpers and "create_backup" in helpers: + helpers["create_backup"](exclude=["timelapse", "uploads"]) + + By using the ``if helpers [...]`` clause, plugins can fall back to other methods + when they are running under versions where these helpers did not exist. + + + :param list exclude: Names of data folders to exclude, defaults to None + :param str filename: Name of backup to be created, if None (default) the backup + name will be auto-generated. This should use a ``.zip`` extension. + """ + if exclude is None: + exclude = [] + if not isinstance(exclude, list): + exclude = list(exclude) + + self._start_backup(exclude, filename=filename) + + def delete_backup_helper(self, filename): + """ + .. versionadded:: 1.6.0 + + Delete the specified backup from a plugin or other internal call + + This helper is exported as ``delete_backup`` and can be used through the plugin + manager's ``get_helpers`` method. + + **Example** + The following code snippet can be used from within a plugin, and will attempt to + delete the backup named ``ExampleBackup.zip``. + + .. code-block:: python + + helpers = self._plugin_manager.get_helpers("backup", "delete_backup") + + if helpers and "delete_backup" in helpers: + helpers["delete_backup"]("ExampleBackup.zip") + + By using the ``if helpers [...]`` clause, plugins can fall back to other methods + when they are running under versions where these helpers did not exist. + + .. warning:: + + This method will fail silently if the backup does not exist, and so + it is recommended that you make sure the name comes from a verified source, + for example the name from the events or other helpers. + + :param str filename: The name of the backup to delete + """ + self._delete_backup(filename) + + ##~~ CLI hook + + def cli_commands_hook(self, cli_group, pass_octoprint_ctx, *args, **kwargs): + import click + + @click.command("backup") + @click.option( + "--exclude", + multiple=True, + help="Identifiers of data folders to exclude, e.g. 'uploads' to exclude uploads or " + "'timelapse' to exclude timelapses.", + ) + @click.option( + "--path", + type=click.Path(), + default=None, + help="Specify full path to backup file to be created", + ) + def backup_command(exclude, path): + """ + Creates a new backup. + """ + + settings = octoprint.plugin.plugin_settings_for_settings_plugin( + "backup", self, settings=cli_group.settings + ) + + if path is not None: + datafolder, filename = os.path.split(os.path.abspath(path)) + else: + filename = self._build_backup_filename(settings=settings) + datafolder = os.path.join(settings.getBaseFolder("data"), "backup") + + if not os.path.isdir(datafolder): + os.makedirs(datafolder) + + click.echo("Creating backup at {}, please wait...".format(filename)) + self._create_backup( + filename, + exclude=exclude, + settings=settings, + plugin_manager=cli_group.plugin_manager, + datafolder=datafolder, + ) + click.echo("Done.") + click.echo("Backup located at {}".format(os.path.join(datafolder, filename))) + + @click.command("restore") + @click.argument("path") + def restore_command(path): + """ + Restores an existing backup from the backup zip provided as argument. + + OctoPrint does not need to run for this to proceed. + """ + settings = octoprint.plugin.plugin_settings_for_settings_plugin( + "backup", self, settings=cli_group.settings + ) + plugin_manager = cli_group.plugin_manager + + datafolder = os.path.join(settings.getBaseFolder("data"), "backup") + if not os.path.isdir(datafolder): + os.makedirs(datafolder) + + # register plugin manager plugin setting overlays + plugin_info = plugin_manager.get_plugin_info("pluginmanager") + if plugin_info and plugin_info.implementation: + default_settings_overlay = {"plugins": {}} + default_settings_overlay["plugins"][ + "pluginmanager" + ] = plugin_info.implementation.get_settings_defaults() + settings.add_overlay(default_settings_overlay, at_end=True) + + if not os.path.isabs(path): + datafolder = os.path.join(settings.getBaseFolder("data"), "backup") + if not os.path.isdir(datafolder): + os.makedirs(datafolder) + path = os.path.join(datafolder, path) + + if not os.path.exists(path): + click.echo("Backup {} does not exist".format(path), err=True) + sys.exit(-1) + + archive = tempfile.NamedTemporaryFile(delete=False) + archive.close() + shutil.copy(path, archive.name) + path = archive.name + + def on_install_plugins(plugins): + if not plugins: + return + + force_user = settings.global_get_boolean( + ["plugins", "pluginmanager", "pip_force_user"] + ) + pip_args = settings.global_get(["plugins", "pluginmanager", "pip_args"]) + + def log(line): + click.echo("\t{}".format(line)) + + for plugin in plugins: + octoprint_compatible = is_octoprint_compatible( + *plugin["compatibility"]["octoprint"] + ) + os_compatible = is_os_compatible(plugin["compatibility"]["os"]) + compatible = octoprint_compatible and os_compatible + if not compatible: + if not octoprint_compatible and not os_compatible: + click.echo( + "Cannot install plugin {}, it is incompatible to this version of " + "OctoPrint and the underlying operating system".format( + plugin["id"] + ) + ) + elif not octoprint_compatible: + click.echo( + "Cannot install plugin {}, it is incompatible to this version of " + "OctoPrint".format(plugin["id"]) + ) + elif not os_compatible: + click.echo( + "Cannot install plugin {}, it is incompatible to the underlying " + "operating system".format(plugin["id"]) + ) + continue + + click.echo("Installing plugin {}".format(plugin["id"])) + self.__class__._install_plugin( + plugin, + force_user=force_user, + pip_command=settings.global_get( + ["server", "commands", "localPipCommand"] + ), + pip_args=pip_args, + on_log=log, + ) + + def on_report_unknown_plugins(plugins): + if not plugins: + return + + click.echo( + "The following plugins were not found in the plugin repository. You'll need to install them manually." + ) + for plugin in plugins: + click.echo( + "\t{} (Homepage: {})".format( + plugin["name"], plugin["url"] if plugin["url"] else "?" + ) + ) + + def on_log_progress(line): + click.echo(line) + + def on_log_error(line, exc_info=None): + click.echo(line, err=True) + + if exc_info is not None: + exc_type, exc_value, exc_tb = exc_info + output = traceback.format_exception(exc_type, exc_value, exc_tb) + for line in output: + click.echo(line.rstrip(), err=True) + + if self._restore_backup( + path, + settings=settings, + plugin_manager=plugin_manager, + datafolder=datafolder, + on_install_plugins=on_install_plugins, + on_report_unknown_plugins=on_report_unknown_plugins, + on_log_progress=on_log_progress, + on_log_error=on_log_error, + on_invalid_backup=on_log_error, + ): + click.echo("Restored from {}".format(path)) + else: + click.echo("Restoring from {} failed".format(path), err=True) + + return [backup_command, restore_command] + + ##~~ helpers + + def _start_backup(self, exclude, filename=None): + if filename is None: + filename = self._build_backup_filename(settings=self._settings) + + def on_backup_start(name, temporary_path, exclude): + self._logger.info( + "Creating backup zip at {} (excluded: {})...".format( + temporary_path, ",".join(exclude) if len(exclude) else "-" + ) + ) + + with self._in_progress_lock: + self._in_progress.append(name) + self._send_client_message("backup_started", payload={"name": name}) + + def on_backup_done(name, final_path, exclude): + with self._in_progress_lock: + self._in_progress.remove(name) + self._send_client_message("backup_done", payload={"name": name}) + + self._logger.info("... done creating backup zip.") + + self._event_bus.fire( + Events.PLUGIN_BACKUP_BACKUP_CREATED, + {"name": name, "path": final_path, "excludes": exclude}, + ) + + def on_backup_error(name, exc_info): + with self._in_progress_lock: + try: + self._in_progress.remove(name) + except ValueError: + # we'll ignore that + pass + + self._send_client_message( + "backup_error", payload={"name": name, "error": "{}".format(exc_info[1])} + ) + self._logger.error("Error while creating backup zip", exc_info=exc_info) + + thread = threading.Thread( + target=self._create_backup, + args=(filename,), + kwargs={ + "exclude": exclude, + "settings": self._settings, + "plugin_manager": self._plugin_manager, + "logger": self._logger, + "datafolder": self.get_plugin_data_folder(), + "on_backup_start": on_backup_start, + "on_backup_done": on_backup_done, + "on_backup_error": on_backup_error, + }, + ) + thread.daemon = True + thread.start() + + def _delete_backup(self, filename): + """ + Delete the backup specified + Args: + filename (str): Name of backup to delete + """ + backup_folder = self.get_plugin_data_folder() + full_path = os.path.realpath(os.path.join(backup_folder, filename)) + if ( + full_path.startswith(backup_folder) + and os.path.exists(full_path) + and not is_hidden_path(full_path) + ): + try: + os.remove(full_path) + except Exception: + self._logger.exception("Could not delete {}".format(filename)) + raise + + def _get_backups(self): + backups = [] + for entry in scandir(self.get_plugin_data_folder()): + if is_hidden_path(entry.path): + continue + if not entry.is_file(): + continue + if not entry.name.endswith(".zip"): + continue + + backups.append( + { + "name": entry.name, + "date": entry.stat().st_mtime, + "size": entry.stat().st_size, + "url": flask.url_for("index") + + "plugin/backup/download/" + + entry.name, + } + ) + return backups + + def _get_unknown_plugins(self): + data_file = os.path.join(self.get_plugin_data_folder(), UNKNOWN_PLUGINS_FILE) + if os.path.exists(data_file): + try: + with io.open(data_file, mode="rt", encoding="utf-8") as f: + unknown_plugins = json.load(f) + + assert isinstance(unknown_plugins, list) + assert all( + map( + lambda x: isinstance(x, dict) + and "key" in x + and "name" in x + and "url" in x, + unknown_plugins, + ) + ) + + installed_plugins = self._plugin_manager.plugins + unknown_plugins = list( + filter(lambda x: x["key"] not in installed_plugins, unknown_plugins) + ) + if not unknown_plugins: + # no plugins left uninstalled, delete data file + try: + os.remove(data_file) + except Exception: + self._logger.exception( + "Error while deleting list of unknown plugins at {}".format( + data_file + ) + ) + + return unknown_plugins + except Exception: + self._logger.exception( + "Error while reading list of unknown plugins from {}".format( + data_file + ) + ) + try: + os.remove(data_file) + except Exception: + self._logger.exception( + "Error while deleting list of unknown plugins at {}".format( + data_file + ) + ) + + return [] + + @classmethod + def _clean_dir_backup(cls, basedir, on_log_progress=None): + basedir_backup = basedir + ".bck" + + if os.path.exists(basedir_backup): + + def remove_bck(): + if callable(on_log_progress): + on_log_progress( + "Found config folder backup from prior restore, deleting it..." + ) + shutil.rmtree(basedir_backup) + if callable(on_log_progress): + on_log_progress("... deleted.") + + thread = threading.Thread(target=remove_bck) + thread.daemon = True + thread.start() + + @classmethod + def _get_disk_size(cls, path, ignored=None): + if ignored is None: + ignored = [] + + if path in ignored: + return 0 + + total = 0 + for entry in scandir(path): + if entry.is_dir(): + total += cls._get_disk_size(entry.path, ignored=ignored) + elif entry.is_file(): + total += entry.stat().st_size + return total + + @classmethod + def _free_space(cls, path, size): + from psutil import disk_usage + + return disk_usage(path).free > size + + @classmethod + def _get_plugin_repository_data(cls, url, logger=None): + if logger is None: + logger = logging.getLogger(__name__) + + try: + r = requests.get(url, timeout=30) + r.raise_for_status() + except Exception: + logger.exception( + "Error while fetching the plugin repository data from {}".format(url) + ) + return {} + + from octoprint.plugins.pluginmanager import map_repository_entry + + return {plugin["id"]: plugin for plugin in map(map_repository_entry, r.json())} + + @classmethod + def _install_plugin( + cls, plugin, force_user=False, pip_command=None, pip_args=None, on_log=None + ): + if pip_args is None: + pip_args = [] + + if on_log is None: + on_log = logging.getLogger(__name__).info + + # prepare pip caller + def log(prefix, *lines): + for line in lines: + on_log("{} {}".format(prefix, line.rstrip())) + + def log_call(*lines): + log(">", *lines) + + def log_stdout(*lines): + log("<", *lines) + + def log_stderr(*lines): + log("!", *lines) + + if cls._pip_caller is None: + cls._pip_caller = create_pip_caller( + command=pip_command, force_user=force_user + ) + + cls._pip_caller.on_log_call = log_call + cls._pip_caller.on_log_stdout = log_stdout + cls._pip_caller.on_log_stderr = log_stderr + + # install plugin + pip = ["install", sarge.shell_quote(plugin["archive"]), "--no-cache-dir"] + + if plugin.get("follow_dependency_links"): + pip.append("--process-dependency-links") + + if force_user: + pip.append("--user") + + if pip_args: + pip += pip_args + + cls._pip_caller.execute(*pip) + + @classmethod + def _create_backup( + cls, + name, + exclude=None, + settings=None, + plugin_manager=None, + logger=None, + datafolder=None, + on_backup_start=None, + on_backup_done=None, + on_backup_error=None, + ): + if logger is None: + logger = logging.getLogger(__name__) + + exclude_by_default = ( + "generated", + "logs", + "watched", + ) + + try: + if exclude is None: + exclude = [] + if not isinstance(exclude, list): + exclude = list(exclude) + + if "timelapse" in exclude: + exclude.append("timelapse_tmp") + + current_excludes = list(exclude) + additional_excludes = list() + plugin_data = settings.global_get_basefolder("data") + for plugin, hook in plugin_manager.get_hooks( + "octoprint.plugin.backup.additional_excludes" + ).items(): + try: + additional = hook(current_excludes) + if isinstance(additional, list): + if "." in additional: + current_excludes.append(os.path.join("data", plugin)) + additional_excludes.append(os.path.join(plugin_data, plugin)) + else: + current_excludes += map( + lambda x: os.path.join("data", plugin, x), additional + ) + additional_excludes += map( + lambda x: os.path.join(plugin_data, plugin, x), additional + ) + except Exception: + logger.exception( + "Error while retrieving additional excludes " + "from plugin {name}".format(**locals()), + extra={"plugin": plugin}, + ) + + configfile = settings._configfile + basedir = settings._basedir + + temporary_path = os.path.join(datafolder, ".{}".format(name)) + final_path = os.path.join(datafolder, name) + + own_folder = datafolder + defaults = [os.path.join(basedir, "config.yaml"),] + [ + os.path.join(basedir, folder) + for folder in default_settings["folder"].keys() + ] + + # check how many bytes we are about to backup + size = os.stat(configfile).st_size + for folder in default_settings["folder"].keys(): + if folder in exclude or folder in exclude_by_default: + continue + size += cls._get_disk_size( + settings.global_get_basefolder(folder), + ignored=[ + own_folder, + ], + ) + size += cls._get_disk_size( + basedir, + ignored=defaults + + [ + own_folder, + ], + ) + + # since we can't know the compression ratio beforehand, we assume we need the same amount of space + if not cls._free_space(os.path.dirname(temporary_path), size): + raise InsufficientSpace() + + compression = zipfile.ZIP_DEFLATED if zlib else zipfile.ZIP_STORED + + if callable(on_backup_start): + on_backup_start(name, temporary_path, exclude) + + with zipfile.ZipFile( + temporary_path, mode="w", compression=compression, allowZip64=True + ) as zip: + + def add_to_zip(source, target, ignored=None): + if ignored is None: + ignored = [] + + if source in ignored: + return + + if os.path.isdir(source): + for entry in scandir(source): + add_to_zip( + entry.path, + os.path.join(target, entry.name), + ignored=ignored, + ) + elif os.path.isfile(source): + zip.write(source, arcname=target) + + # add metadata + metadata = { + "version": get_octoprint_version_string(), + "excludes": exclude, + } + zip.writestr("metadata.json", json.dumps(metadata)) + + # backup current config file + add_to_zip( + configfile, + "basedir/config.yaml", + ignored=[ + own_folder, + ], + ) + + # backup configured folder paths + for folder in default_settings["folder"].keys(): + if folder in exclude or folder in exclude_by_default: + continue + + add_to_zip( + settings.global_get_basefolder(folder), + "basedir/" + folder.replace("_", "/"), + ignored=[ + own_folder, + ] + + additional_excludes, + ) + + # backup anything else that might be lying around in our basedir + add_to_zip( + basedir, + "basedir", + ignored=defaults + + [ + own_folder, + ] + + additional_excludes, + ) + + # add list of installed plugins + helpers = plugin_manager.get_helpers( + "pluginmanager", "generate_plugins_json" + ) + if helpers and "generate_plugins_json" in helpers: + plugins = helpers["generate_plugins_json"]( + settings=settings, plugin_manager=plugin_manager + ) + + if len(plugins): + zip.writestr("plugin_list.json", json.dumps(plugins)) + + shutil.move(temporary_path, final_path) + + if callable(on_backup_done): + on_backup_done(name, final_path, exclude) + + except Exception as exc: # noqa: F841 + # TODO py3: use the exception, not sys.exc_info() + if callable(on_backup_error): + exc_info = sys.exc_info() + try: + on_backup_error(name, exc_info) + finally: + del exc_info + raise + + @classmethod + def _restore_backup( + cls, + path, + settings=None, + plugin_manager=None, + datafolder=None, + on_install_plugins=None, + on_report_unknown_plugins=None, + on_invalid_backup=None, + on_log_progress=None, + on_log_error=None, + on_restore_start=None, + on_restore_done=None, + on_restore_failed=None, + ): + if not cls._restore_supported(settings): + if callable(on_log_error): + on_log_error("Restore is not supported on this operating system") + if callable(on_restore_failed): + on_restore_failed(path) + return False + + restart_command = settings.global_get( + ["server", "commands", "serverRestartCommand"] + ) + + basedir = settings._basedir + cls._clean_dir_backup(basedir, on_log_progress=on_log_progress) + + repo_url = settings.global_get(["plugins", "pluginmanager", "repository"]) + if not repo_url: + repo_url = DEFAULT_PLUGIN_REPOSITORY + + plugin_repo = cls._get_plugin_repository_data(repo_url) + + if callable(on_restore_start): + on_restore_start(path) + + try: + + with zipfile.ZipFile(path, "r") as zip: + # read metadata + try: + metadata_zipinfo = zip.getinfo("metadata.json") + except KeyError: + if callable(on_invalid_backup): + on_invalid_backup("Not an OctoPrint backup, lacks metadata.json") + if callable(on_restore_failed): + on_restore_failed(path) + return False + + metadata_bytes = zip.read(metadata_zipinfo) + metadata = json.loads(metadata_bytes) + + backup_version = get_comparable_version(metadata["version"], cut=1) + if backup_version > get_octoprint_version(cut=1): + if callable(on_invalid_backup): + on_invalid_backup( + "Backup is from a newer version of OctoPrint and cannot be applied" + ) + if callable(on_restore_failed): + on_restore_failed(path) + return False + + # unzip to temporary folder + temp = tempfile.mkdtemp() + try: + if callable(on_log_progress): + on_log_progress("Unpacking backup to {}...".format(temp)) + + abstemp = os.path.abspath(temp) + dirs = {} + for member in zip.infolist(): + abspath = os.path.abspath(os.path.join(temp, member.filename)) + if abspath.startswith(abstemp): + date_time = time.mktime(member.date_time + (0, 0, -1)) + + zip.extract(member, temp) + + if os.path.isdir(abspath): + dirs[abspath] = date_time + else: + os.utime(abspath, (date_time, date_time)) + + # set time on folders + for abspath, date_time in dirs.items(): + os.utime(abspath, (date_time, date_time)) + + # sanity check + configfile = os.path.join(temp, "basedir", "config.yaml") + if not os.path.exists(configfile): + if callable(on_invalid_backup): + on_invalid_backup("Backup lacks config.yaml") + if callable(on_restore_failed): + on_restore_failed(path) + return False + + import yaml + + with io.open(configfile, "rt", encoding="utf-8") as f: + configdata = yaml.safe_load(f) + + userfile = os.path.join(temp, "basedir", "users.yaml") + if not os.path.exists(userfile): + if callable(on_invalid_backup): + on_invalid_backup("Backup lacks users.yaml") + if callable(on_restore_failed): + on_restore_failed(path) + return False + + if callable(on_log_progress): + on_log_progress("Unpacked") + + # install available plugins + plugins = [] + plugin_list_file = os.path.join(temp, "plugin_list.json") + if os.path.exists(plugin_list_file): + with io.open(os.path.join(temp, "plugin_list.json"), "rb") as f: + plugins = json.load(f) + + known_plugins = [] + unknown_plugins = [] + if plugins: + if plugin_repo: + for plugin in plugins: + if plugin["key"] in plugin_manager.plugins: + # already installed + continue + + if plugin["key"] in plugin_repo: + # not installed, can be installed from repository url + known_plugins.append(plugin_repo[plugin["key"]]) + else: + # not installed, not installable + unknown_plugins.append(plugin) + + else: + # no repo, all plugins are not installable + unknown_plugins = plugins + + if callable(on_log_progress): + if known_plugins: + on_log_progress( + "Known and installable plugins: {}".format( + ", ".join(map(lambda x: x["id"], known_plugins)) + ) + ) + if unknown_plugins: + on_log_progress( + "Unknown plugins: {}".format( + ", ".join( + map(lambda x: x["key"], unknown_plugins) + ) + ) + ) + + if callable(on_install_plugins): + on_install_plugins(known_plugins) + + if callable(on_report_unknown_plugins): + on_report_unknown_plugins(unknown_plugins) + + # move config data + basedir_backup = basedir + ".bck" + basedir_extracted = os.path.join(temp, "basedir") + + if callable(on_log_progress): + on_log_progress( + "Renaming {} to {}...".format(basedir, basedir_backup) + ) + shutil.move(basedir, basedir_backup) + + try: + if callable(on_log_progress): + on_log_progress( + "Moving {} to {}...".format(basedir_extracted, basedir) + ) + shutil.move(basedir_extracted, basedir) + except Exception: + if callable(on_log_error): + on_log_error( + "Error while restoring config data", + exc_info=sys.exc_info(), + ) + on_log_error("Rolling back old config data") + + shutil.move(basedir_backup, basedir) + + if callable(on_restore_failed): + on_restore_failed(path) + return False + + if unknown_plugins: + if callable(on_log_progress): + on_log_progress("Writing info file about unknown plugins") + + if not os.path.isdir(datafolder): + os.makedirs(datafolder) + + unknown_plugins_path = os.path.join( + datafolder, UNKNOWN_PLUGINS_FILE + ) + try: + with io.open(unknown_plugins_path, mode="wb") as f: + f.write(to_bytes(json.dumps(unknown_plugins))) + except Exception: + if callable(on_log_error): + on_log_error( + "Could not persist list of unknown plugins to {}".format( + unknown_plugins_path + ), + exc_info=sys.exc_info(), + ) + + finally: + if callable(on_log_progress): + on_log_progress("Removing temporary unpacked folder") + shutil.rmtree(temp) + + except Exception: + exc_info = sys.exc_info() + try: + if callable(on_log_error): + on_log_error("Error while running restore", exc_info=exc_info) + if callable(on_restore_failed): + on_restore_failed(path) + finally: + del exc_info + return False + + finally: + # remove zip + if callable(on_log_progress): + on_log_progress("Removing temporary zip") + os.remove(path) + + # restart server + if not restart_command: + restart_command = ( + configdata.get("server", {}) + .get("commands", {}) + .get("serverRestartCommand") + ) + + if restart_command: + import sarge + + if callable(on_log_progress): + on_log_progress("Restarting...") + if callable(on_restore_done): + on_restore_done(path) + + try: + sarge.run(restart_command, close_fds=True, async_=True) + except Exception: + if callable(on_log_error): + on_log_error( + "Error while restarting via command {}".format(restart_command), + exc_info=sys.exc_info(), + ) + on_log_error("Please restart OctoPrint manually") + return False + + else: + if callable(on_restore_done): + on_restore_done(path) + if callable(on_log_error): + on_log_error( + "No restart command configured. Please restart OctoPrint manually." + ) + + return True + + @classmethod + def _build_backup_filename(cls, settings): + if settings.global_get(["appearance", "name"]) == "": + backup_prefix = "octoprint" + else: + backup_prefix = settings.global_get(["appearance", "name"]) + backup_prefix = sanitize(backup_prefix) + return "{}-backup-{}.zip".format( + backup_prefix, time.strftime(BACKUP_DATE_TIME_FMT) + ) + + @classmethod + def _restore_supported(cls, settings): + return ( + is_os_compatible(["!windows"]) + and not settings.get_boolean(["restore_unsupported"]) + and os.environ.get("OCTOPRINT_BACKUP_RESTORE_UNSUPPORTED", False) + not in valid_boolean_trues + ) + + def _send_client_message(self, message, payload=None): + if payload is None: + payload = {} + payload["type"] = message + self._plugin_manager.send_plugin_message(self._identifier, payload) + + +class InsufficientSpace(Exception): + pass + + +def _register_custom_events(*args, **kwargs): + return ["backup_created"] + + +__plugin_name__ = "Backup & Restore" +__plugin_author__ = "Gina Häußge" +__plugin_description__ = "Backup & restore your OctoPrint settings and data" +__plugin_disabling_discouraged__ = gettext( + "Without this plugin you will no longer be able to backup " + "& restore your OctoPrint settings and data." +) +__plugin_license__ = "AGPLv3" +__plugin_pythoncompat__ = ">=2.7,<4" +__plugin_implementation__ = BackupPlugin() +__plugin_hooks__ = { + "octoprint.server.http.routes": __plugin_implementation__.route_hook, + "octoprint.server.http.bodysize": __plugin_implementation__.bodysize_hook, + "octoprint.cli.commands": __plugin_implementation__.cli_commands_hook, + "octoprint.access.permissions": __plugin_implementation__.get_additional_permissions, + "octoprint.events.register_custom_events": _register_custom_events, + "octoprint.server.sockjs.emit": __plugin_implementation__.socket_emit_hook, +} +__plugin_helpers__ = { + "create_backup": __plugin_implementation__.create_backup_helper, + "delete_backup": __plugin_implementation__.delete_backup_helper, +} diff --git a/src/octoprint/plugins/backup/static/clientjs/backup.js b/src/octoprint/plugins/backup/static/clientjs/backup.js new file mode 100644 index 0000000000..179588a7ad --- /dev/null +++ b/src/octoprint/plugins/backup/static/clientjs/backup.js @@ -0,0 +1,52 @@ +(function (global, factory) { + if (typeof define === "function" && define.amd) { + define(["OctoPrintClient"], factory); + } else { + factory(global.OctoPrintClient); + } +})(this, function (OctoPrintClient) { + var OctoPrintBackupClient = function (base) { + this.base = base; + this.url = this.base.getBlueprintUrl("backup"); + }; + + OctoPrintBackupClient.prototype.get = function (refresh, opts) { + return this.base.get(this.url, opts); + }; + + OctoPrintBackupClient.prototype.createBackup = function (exclude, opts) { + exclude = exclude || []; + + var data = { + exclude: exclude + }; + + return this.base.postJson(this.url + "backup", data, opts); + }; + + OctoPrintBackupClient.prototype.deleteBackup = function (backup, opts) { + return this.base.delete(this.url + "backup/" + backup, opts); + }; + + OctoPrintBackupClient.prototype.restoreBackup = function (backup, opts) { + var data = { + path: backup + }; + + return this.base.postJson(this.url + "restore", data, opts); + }; + + OctoPrintBackupClient.prototype.restoreBackupFromUpload = function (file, data) { + data = data || {}; + + var filename = data.filename || undefined; + return this.base.upload(this.url + "restore", file, filename, data); + }; + + OctoPrintBackupClient.prototype.deleteUnknownPlugins = function (opts) { + return this.base.delete(this.url + "unknown_plugins", opts); + }; + + OctoPrintClient.registerPluginComponent("backup", OctoPrintBackupClient); + return OctoPrintBackupClient; +}); diff --git a/src/octoprint/plugins/backup/static/css/backup.css b/src/octoprint/plugins/backup/static/css/backup.css new file mode 100644 index 0000000000..f3561ce93a --- /dev/null +++ b/src/octoprint/plugins/backup/static/css/backup.css @@ -0,0 +1 @@ +#settings_plugin_backup_backup_table .settings_plugin_backup_actions{width:75px;text-align:center}#settings_plugin_backup_backup_table .settings_plugin_backup_actions a{color:#000}#settings_plugin_backup_backup_table .settings_plugin_backup_date{width:200px;text-align:right}#settings_plugin_backup_backup_table .settings_plugin_backup_size{width:100px;text-align:right}#settings_plugin_backup_backup_table .settings_plugin_backup_checkbox{text-align:center;width:10px}#settings_plugin_backup_backup_table .settings_plugin_backup_checkbox input[type=checkbox]{margin-top:0}#settings_plugin_backup_restoredialog_output .message{font-weight:700}#settings_plugin_backup_restoredialog_output .error{font-weight:700;color:#900}#settings_plugin_backup_restoredialog_output .stdout{color:#333}#settings_plugin_backup_restoredialog_output .stderr{color:#900}#settings_plugin_backup_restoredialog_output .call{color:#009} \ No newline at end of file diff --git a/src/octoprint/plugins/backup/static/js/backup.js b/src/octoprint/plugins/backup/static/js/backup.js new file mode 100644 index 0000000000..106fb75ce6 --- /dev/null +++ b/src/octoprint/plugins/backup/static/js/backup.js @@ -0,0 +1,369 @@ +$(function () { + function BackupViewModel(parameters) { + var self = this; + + self.loginState = parameters[0]; + self.settings = parameters[1]; + + self.backups = new ItemListHelper( + "plugin.backup.backups", + { + date: function (a, b) { + // sorts descending + if (a["date"] > b["date"]) return -1; + if (a["date"] < b["date"]) return 1; + return 0; + } + }, + {}, + "date", + [], + [], + 10 + ); + + self.markedForBackupDeletion = ko.observableArray([]); + + self.excludeFromBackup = ko.observableArray([]); + self.backupInProgress = ko.observable(false); + self.restoreSupported = ko.observable(true); + self.maxUploadSize = ko.observable(0); + + self.backupUploadData = undefined; + self.backupUploadName = ko.observable(); + + self.isAboveUploadSize = function (data) { + return data.size > self.maxUploadSize(); + }; + + var backupFileuploadOptions = { + dataType: "json", + maxNumberOfFiles: 1, + autoUpload: false, + headers: OctoPrint.getRequestHeaders(), + add: function (e, data) { + if (data.files.length === 0) { + // no files? ignore + return false; + } + + self.backupUploadName(data.files[0].name); + self.backupUploadData = data; + }, + done: function (e, data) { + self.backupUploadName(undefined); + self.backupUploadData = undefined; + } + }; + + $("#settings-backup-upload").fileupload(backupFileuploadOptions); + $("#wizard-backup-upload").fileupload(backupFileuploadOptions); + + self.restoreInProgress = ko.observable(false); + self.restoreTitle = ko.observable(); + self.restoreDialog = undefined; + self.restoreOutput = undefined; + self.unknownPlugins = ko.observableArray([]); + + self.loglines = ko.observableArray([]); + + self.requestData = function () { + OctoPrint.plugins.backup.get().done(self.fromResponse); + }; + + self.fromResponse = function (response) { + self.backups.updateItems(response.backups); + self.unknownPlugins(response.unknown_plugins); + self.restoreSupported(response.restore_supported); + self.maxUploadSize(response.max_upload_size); + }; + + self.createBackup = function () { + var excluded = self.excludeFromBackup(); + OctoPrint.plugins.backup.createBackup(excluded).done(function () { + self.excludeFromBackup([]); + }); + }; + + self.removeBackup = function (backup) { + var perform = function () { + OctoPrint.plugins.backup.deleteBackup(backup).done(function () { + self.requestData(); + }); + }; + showConfirmationDialog( + _.sprintf(gettext('You are about to delete backup file "%(name)s".'), { + name: _.escape(backup) + }), + perform + ); + }; + + self.restoreBackup = function (backup) { + if (!self.restoreSupported()) return; + + var perform = function () { + self.restoreInProgress(true); + self.loglines.removeAll(); + self.loglines.push({line: "Preparing to restore...", stream: "message"}); + self.loglines.push({line: " ", stream: "message"}); + self.restoreDialog.modal({ + keyboard: false, + backdrop: "static", + show: true + }); + + OctoPrint.plugins.backup.restoreBackup(backup); + }; + showConfirmationDialog( + _.sprintf(gettext('You are about to restore the backup file "%(name)s". This cannot be undone.'), { + name: _.escape(backup) + }), + perform + ); + }; + + self.performRestoreFromUpload = function () { + if (self.backupUploadData === undefined) return; + + var perform = function () { + self.restoreInProgress(true); + self.loglines.removeAll(); + self.loglines.push({ + line: "Uploading backup, this can take a while. Please wait...", + stream: "message" + }); + self.loglines.push({line: " ", stream: "message"}); + self.restoreDialog.modal({ + keyboard: false, + backdrop: "static", + show: true + }); + + self.backupUploadData.submit(); + }; + showConfirmationDialog( + _.sprintf( + gettext('You are about to upload and restore the backup file "%(name)s". This cannot be undone.'), + {name: _.escape(self.backupUploadName())} + ), + perform + ); + }; + + self.deleteUnknownPluginRecord = function () { + var perform = function () { + OctoPrint.plugins.backup.deleteUnknownPlugins().done(function () { + self.requestData(); + }); + }; + showConfirmationDialog( + gettext("You are about to delete the record of plugins unknown during the last restore."), + perform + ); + }; + + self.markFilesOnPage = function () { + self.markedForBackupDeletion( + _.uniq(self.markedForBackupDeletion().concat(_.map(self.backups.paginatedItems(), "name"))) + ); + }; + + self.markAllFiles = function () { + self.markedForBackupDeletion(_.map(self.backups.allItems, "name")); + }; + + self.clearMarkedFiles = function () { + self.markedForBackupDeletion.removeAll(); + }; + + self.removeMarkedFiles = function () { + var perform = function () { + self._bulkRemove(self.markedForBackupDeletion()).done(function () { + self.markedForBackupDeletion.removeAll(); + }); + }; + + showConfirmationDialog( + _.sprintf(gettext("You are about to delete %(count)d backups."), { + count: self.markedForBackupDeletion().length + }), + perform + ); + }; + + self.onStartup = function () { + self.restoreDialog = $("#settings_plugin_backup_restoredialog"); + self.restoreOutput = $("#settings_plugin_backup_restoredialog_output"); + }; + + self.onSettingsShown = function () { + self.requestData(); + }; + + self.onDataUpdaterPluginMessage = function (plugin, data) { + if (plugin !== "backup") return; + + if (data.type === "backup_done") { + self.requestData(); + self.backupInProgress(false); + new PNotify({ + title: gettext("Backup created successfully"), + type: "success" + }); + } else if (data.type === "backup_started") { + self.backupInProgress(true); + } else if (data.type === "backup_error") { + self.requestData(); + self.backupInProgress(false); + new PNotify({ + title: gettext("Creating the backup failed"), + text: _.sprintf( + gettext( + "OctoPrint could not create your backup. Please consult octoprint.log for details. Error: %(error)s" + ), + {error: _.escape(data.error)} + ), + type: "error", + hide: false + }); + } else if (data.type === "restore_started") { + self.loglines.push({ + line: gettext("Restoring from backup..."), + stream: "message" + }); + self.loglines.push({line: " ", stream: "message"}); + } else if (data.type === "restore_failed") { + self.loglines.push({line: " ", stream: "message"}); + self.loglines.push({ + line: gettext("Restore failed! Check the above output and octoprint.log for reasons as to why."), + stream: "error" + }); + self.restoreInProgress(false); + } else if (data.type === "restore_done") { + self.loglines.push({line: " ", stream: "message"}); + self.loglines.push({ + line: gettext("Restore successful! The server will now be restarted!"), + stream: "message" + }); + self.restoreInProgress(false); + } else if (data.type === "installing_plugin") { + self.loglines.push({line: " ", stream: "message"}); + self.loglines.push({ + line: _.sprintf(gettext('Installing plugin "%(plugin)s"...'), { + plugin: _.escape(data.plugin) + }), + stream: "message" + }); + } else if (data.type === "plugin_incompatible") { + self.loglines.push({line: " ", stream: "message"}); + self.loglines.push({ + line: _.sprintf( + gettext( + 'Cannot install plugin "%(plugin)s" due to it being incompatible to this OctoPrint version and/or underlying operating system' + ), + {plugin: _.escape(data.plugin.key)} + ), + stream: "stderr" + }); + } else if (data.type === "unknown_plugins") { + if (data.plugins.length > 0) { + self.loglines.push({line: " ", stream: "message"}); + self.loglines.push({ + line: _.sprintf( + gettext( + "There are %(count)d plugins you'll need to install manually since they aren't registered on the repository:" + ), + {count: data.plugins.length} + ), + stream: "message" + }); + _.each(data.plugins, function (plugin) { + self.loglines.push({ + line: plugin.name + ": " + plugin.url, + stream: "message" + }); + }); + self.loglines.push({line: " ", stream: "message"}); + self.unknownPlugins(data.plugins); + } + } else if (data.type === "logline") { + self.loglines.push(self._preprocessLine({line: data.line, stream: data.stream})); + self._scrollRestoreOutputToEnd(); + } + }; + + self._scrollRestoreOutputToEnd = function () { + self.restoreOutput.scrollTop(self.restoreOutput[0].scrollHeight - self.restoreOutput.height()); + }; + + self._forcedStdoutLine = /You are using pip version .*?, however version .*? is available\.|You should consider upgrading via the '.*?' command\./; + self._preprocessLine = function (line) { + if (line.stream === "stderr" && line.line.match(self._forcedStdoutLine)) { + line.stream = "stdout"; + } + return line; + }; + + self._bulkRemove = function (files) { + var title, message, handler; + + title = gettext("Deleting backups"); + message = _.sprintf(gettext("Deleting %(count)d backups..."), { + count: files.length + }); + handler = function (filename) { + return OctoPrint.plugins.backup + .deleteBackup(filename) + .done(function () { + deferred.notify( + _.sprintf(gettext("Deleted %(filename)s..."), { + filename: _.escape(filename) + }), + true + ); + }) + .fail(function (jqXHR) { + var short = _.sprintf(gettext("Deletion of %(filename)s failed, continuing..."), { + filename: _.escape(filename) + }); + var long = _.sprintf(gettext("Deletion of %(filename)s failed: %(error)s"), { + filename: _.escape(filename), + error: _.escape(jqXHR.responseText) + }); + deferred.notify(short, long, false); + }); + }; + + var deferred = $.Deferred(); + + var promise = deferred.promise(); + + var options = { + title: title, + message: message, + max: files.length, + output: true + }; + showProgressModal(options, promise); + + var requests = []; + _.each(files, function (filename) { + var request = handler(filename); + requests.push(request); + }); + $.when.apply($, _.map(requests, wrapPromiseWithAlways)).done(function () { + deferred.resolve(); + self.requestData(); + }); + + return promise; + }; + } + + OCTOPRINT_VIEWMODELS.push({ + construct: BackupViewModel, + dependencies: ["loginStateViewModel", "settingsViewModel"], + elements: ["#settings_plugin_backup", "#wizard_plugin_backup"] + }); +}); diff --git a/src/octoprint/plugins/backup/static/less/backup.less b/src/octoprint/plugins/backup/static/less/backup.less new file mode 100644 index 0000000000..6f90681ff9 --- /dev/null +++ b/src/octoprint/plugins/backup/static/less/backup.less @@ -0,0 +1,52 @@ +#settings_plugin_backup_backup_table { + .settings_plugin_backup_name { + } + .settings_plugin_backup_actions { + width: 75px; + text-align: center; + + a { + color: black; + } + } + .settings_plugin_backup_date { + width: 200px; + text-align: right; + } + .settings_plugin_backup_size { + width: 100px; + text-align: right; + } + + .settings_plugin_backup_checkbox { + text-align: center; + width: 10px; + + input[type="checkbox"] { + margin-top: 0; + } + } +} + +#settings_plugin_backup_restoredialog_output { + .message { + font-weight: bold; + } + + .error { + font-weight: bold; + color: #990000; + } + + .stdout { + color: #333333; + } + + .stderr { + color: #990000; + } + + .call { + color: #000099; + } +} diff --git a/src/octoprint/plugins/backup/templates/backup_settings.jinja2 b/src/octoprint/plugins/backup/templates/backup_settings.jinja2 new file mode 100644 index 0000000000..0d2fd81fe9 --- /dev/null +++ b/src/octoprint/plugins/backup/templates/backup_settings.jinja2 @@ -0,0 +1,128 @@ +
+ {% trans %} + Some plugins during the last restore could not be identified and hence not automatically installed. + Please install them manually, they are listed below including their stated homepages: + {% endtrans %} +
    +
  • :
  • +
+ {{ _('Delete record of unknown plugins') }} +
+ + +

{{ _('Existing backups') }}

+ +
+

{{ _('There are no backups. Maybe create one below?') }}

+
+
+

{{ _('These are the backups of your settings and files that already exist on this OctoPrint instance. You may delete, download or restore them.') }}

+ + + + + + + + + + + + + + + + + + + + + +
{{ _('Name') }}{{ _('Date') }}{{ _('Size') }}{{ _('Action') }}
 |  | 
+ + +

+ {{ _("Note:") }} + {% trans marker="" %} + OctoPrint currently only allows uploading backups of a maximum size of + {{ plugin_backup_max_upload_size_str }} via the UI. Backups larger than this can only be restored by command line, + or by adjusting the size by removing large contents (like timelapses) from the + zip prior to uploading. Backups exceeding this size limit are marked with a + {{ marker }} up there. + {% endtrans %} +

+
+ +

{{ _('Create backup') }}

+ +

{{ _('Create a new backup of the current state.') }}

+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+

{{ _('Restore from uploaded backup') }}

+ +

{{ _('Restore settings & files from an uploaded backup archive.') }}

+ + {% set plugin_backup_upload_form_id='settings-backup-upload' %} + {% include "snippets/plugin/backup/backup_plugin_upload_form.jinja2" %} +
+ +
+ {{ _('Please note that the operating system that this OctoPrint server is running on does not support automatically restoring backups. You will have to restore your backups manually for now.', url='https://faq.octoprint.org/manual-restore') }} +
+ + diff --git a/src/octoprint/plugins/backup/templates/backup_wizard.jinja2 b/src/octoprint/plugins/backup/templates/backup_wizard.jinja2 new file mode 100644 index 0000000000..8ba4f479c7 --- /dev/null +++ b/src/octoprint/plugins/backup/templates/backup_wizard.jinja2 @@ -0,0 +1,8 @@ +

{{ _('Restore from a backup?') }}

+ +{% trans %}

+ If you would like to restore OctoPrint configuration from a backup now is the time. +

{% endtrans %} + +{% set plugin_backup_upload_form_id='wizard-backup-upload' %} +{% include "snippets/plugin/backup/backup_plugin_upload_form.jinja2" %} diff --git a/src/octoprint/plugins/backup/templates/snippets/plugin/backup/backup_plugin_upload_form.jinja2 b/src/octoprint/plugins/backup/templates/snippets/plugin/backup/backup_plugin_upload_form.jinja2 new file mode 100644 index 0000000000..c3ebe5d2e8 --- /dev/null +++ b/src/octoprint/plugins/backup/templates/snippets/plugin/backup/backup_plugin_upload_form.jinja2 @@ -0,0 +1,20 @@ +
+
+ +
+
+ + {{ _('Browse...') }} + + + +
+
+
+ +
+
+ +
+
+
diff --git a/src/octoprint/plugins/corewizard/__init__.py b/src/octoprint/plugins/corewizard/__init__.py index 7cc9fb5f24..5d48c27e4b 100644 --- a/src/octoprint/plugins/corewizard/__init__.py +++ b/src/octoprint/plugins/corewizard/__init__.py @@ -1,126 +1,139 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" +from flask_babel import gettext + import octoprint.plugin -from flask.ext.babel import gettext from .subwizards import Subwizards -class CoreWizardPlugin(octoprint.plugin.AssetPlugin, - octoprint.plugin.TemplatePlugin, - octoprint.plugin.WizardPlugin, - octoprint.plugin.SettingsPlugin, - octoprint.plugin.BlueprintPlugin, - Subwizards): +class CoreWizardPlugin( + octoprint.plugin.AssetPlugin, + octoprint.plugin.TemplatePlugin, + octoprint.plugin.WizardPlugin, + octoprint.plugin.SettingsPlugin, + octoprint.plugin.BlueprintPlugin, + Subwizards, +): + + # ~~ TemplatePlugin API - #~~ TemplatePlugin API + def get_template_configs(self): + required = self._get_subwizard_attrs("_is_", "_wizard_required") + names = self._get_subwizard_attrs("_get_", "_wizard_name") + additional = self._get_subwizard_attrs( + "_get_", "_additional_wizard_template_data" + ) - def get_template_configs(self): - required = self._get_subwizard_attrs("_is_", "_wizard_required") - names = self._get_subwizard_attrs("_get_", "_wizard_name") - additional = self._get_subwizard_attrs("_get_", "_additional_wizard_template_data") + firstrunonly = self._get_subwizard_attrs("_is_", "_wizard_firstrunonly") + firstrun = self._settings.global_get(["server", "firstRun"]) - firstrunonly = self._get_subwizard_attrs("_is_", "_wizard_firstrunonly") - firstrun = self._settings.global_get(["server", "firstRun"]) + if not firstrun: + required = { + key: value + for key, value in required.items() + if not firstrunonly.get(key, lambda: False)() + } - if not firstrun: - required = dict((key, value) for key, value in required.items() - if not firstrunonly.get(key, lambda: False)()) + result = list() + for key, method in required.items(): + if not callable(method): + continue - result = list() - for key, method in required.items(): - if not callable(method): - continue + if not method(): + continue - if not method(): - continue + if key not in names: + continue - if not key in names: - continue + name = names[key]() + if not name: + continue - name = names[key]() - if not name: - continue + config = { + "type": "wizard", + "name": name, + "template": "corewizard_{}_wizard.jinja2".format(key), + "div": "wizard_plugin_corewizard_{}".format(key), + "suffix": "_{}".format(key), + } + if key in additional: + additional_result = additional[key]() + if additional_result: + config.update(additional_result) + result.append(config) - config = dict(type="wizard", - name=name, - template="corewizard_{}_wizard.jinja2".format(key), - div="wizard_plugin_corewizard_{}".format(key), - suffix="_{}".format(key)) - if key in additional: - additional_result = additional[key]() - if additional_result: - config.update(additional_result) - result.append(config) + return result - return result + # ~~ AssetPlugin API - #~~ AssetPlugin API + def get_assets(self): + if self.is_wizard_required(): + return {"js": ["js/corewizard.js"], "css": ["css/corewizard.css"]} + else: + return {} - def get_assets(self): - if self.is_wizard_required(): - return dict( - js=["js/corewizard.js"], - css=["css/corewizard.css"] - ) - else: - return dict() + # ~~ WizardPlugin API - #~~ WizardPlugin API + def is_wizard_required(self): + required = self._get_subwizard_attrs("_is_", "_wizard_required") + firstrunonly = self._get_subwizard_attrs("_is_", "_wizard_firstrunonly") + firstrun = self._settings.global_get(["server", "firstRun"]) - def is_wizard_required(self): - required = self._get_subwizard_attrs("_is_", "_wizard_required") - firstrunonly = self._get_subwizard_attrs("_is_", "_wizard_firstrunonly") - firstrun = self._settings.global_get(["server", "firstRun"]) + if not firstrun: + required = { + key: value + for key, value in required.items() + if not firstrunonly.get(key, lambda: False)() + } + any_required = any(map(lambda m: m(), required.values())) - if not firstrun: - required = dict((key, value) for key, value in required.items() - if not firstrunonly.get(key, lambda: False)()) - any_required = any(map(lambda m: m(), required.values())) + return any_required - return any_required + def get_wizard_details(self): + result = {} - def get_wizard_details(self): - result = dict() + def add_result(key, method): + result[key] = method() - def add_result(key, method): - result[key] = method() - self._get_subwizard_attrs("_get_", "_wizard_details", add_result) + self._get_subwizard_attrs("_get_", "_wizard_details", add_result) - return result + return result - def get_wizard_version(self): - return 3 + def get_wizard_version(self): + return 4 - #~~ helpers + # ~~ helpers - def _get_subwizard_attrs(self, start, end, callback=None): - result = dict() + def _get_subwizard_attrs(self, start, end, callback=None): + result = {} - for item in dir(self): - if not item.startswith(start) or not item.endswith(end): - continue + for item in dir(self): + if not item.startswith(start) or not item.endswith(end): + continue - key = item[len(start):-len(end)] - if not key: - continue + key = item[len(start) : -len(end)] + if not key: + continue - attr = getattr(self, item) - if callable(callback): - callback(key, attr) - result[key] = attr + attr = getattr(self, item) + if callable(callback): + callback(key, attr) + result[key] = attr - return result + return result __plugin_name__ = "Core Wizard" __plugin_author__ = "Gina Häußge" __plugin_description__ = "Provides wizard dialogs for core components and functionality" -__plugin_disabling_discouraged__ = gettext("Without this plugin OctoPrint will no longer be able to perform " - "setup steps that might be required after an update.") +__plugin_disabling_discouraged__ = gettext( + "Without this plugin OctoPrint will no longer be able to perform " + "setup steps that might be required after an update." +) __plugin_license__ = "AGPLv3" +__plugin_pythoncompat__ = ">=2.7,<4" __plugin_implementation__ = CoreWizardPlugin() diff --git a/src/octoprint/plugins/corewizard/static/js/corewizard.js b/src/octoprint/plugins/corewizard/static/js/corewizard.js index 1c8f41b7d5..46e35a4509 100644 --- a/src/octoprint/plugins/corewizard/static/js/corewizard.js +++ b/src/octoprint/plugins/corewizard/static/js/corewizard.js @@ -1,4 +1,4 @@ -$(function() { +$(function () { function CoreWizardAclViewModel(parameters) { var self = this; @@ -9,93 +9,87 @@ $(function() { self.confirmedPassword = ko.observable(undefined); self.setup = ko.observable(false); - self.decision = ko.observable(); self.required = false; - self.passwordMismatch = ko.pureComputed(function() { - return self.password() != self.confirmedPassword(); + self.passwordMismatch = ko.pureComputed(function () { + return self.password() !== self.confirmedPassword(); }); - self.validUsername = ko.pureComputed(function() { - return self.username() && self.username().trim() != ""; + self.validUsername = ko.pureComputed(function () { + return self.username() && self.username().trim() !== ""; }); - self.validPassword = ko.pureComputed(function() { - return self.password() && self.password().trim() != ""; + self.validPassword = ko.pureComputed(function () { + return self.password() && self.password().trim() !== ""; }); - self.validData = ko.pureComputed(function() { + self.validData = ko.pureComputed(function () { return !self.passwordMismatch() && self.validUsername() && self.validPassword(); }); - self.keepAccessControl = function() { + self.createAccount = function () { if (!self.validData()) return; var data = { - "ac": true, - "user": self.username(), - "pass1": self.password(), - "pass2": self.confirmedPassword() + user: self.username(), + pass1: self.password(), + pass2: self.confirmedPassword() }; self._sendData(data); }; - self.disableAccessControl = function() { - var message = gettext("If you disable Access Control and your OctoPrint installation is accessible from the internet, your printer will be accessible by everyone - that also includes the bad guys!"); - showConfirmationDialog({ - message: message, - onproceed: function (e) { - var data = { - "ac": false - }; - self._sendData(data); - } + self._sendData = function (data, callback) { + OctoPrint.postJson("plugin/corewizard/acl", data).done(function () { + self.setup(true); + + // we now log the user in + var user = data.user; + var pass = data.pass1; + self.loginStateViewModel.login(user, pass, true).done(function () { + if (callback) callback(); + }); }); }; - self._sendData = function(data, callback) { - OctoPrint.postJson("plugin/corewizard/acl", data) - .done(function() { - self.setup(true); - self.decision(data.ac); - if (data.ac) { - // we now log the user in - var user = data.user; - var pass = data.pass1; - self.loginStateViewModel.login(user, pass, true) - .done(function() { - if (callback) callback(); - }); - } else { - if (callback) callback(); - } - }); + self._showDecisionNeededDialog = function () { + showMessageDialog({ + title: gettext("Please set up Access Control"), + message: gettext( + "You haven't yet set up access control. You need to setup a " + + 'username and password and click "Create Account" before ' + + "continuing." + ) + }); }; - self.onBeforeWizardTabChange = function(next, current) { + self.onBeforeWizardTabChange = function (next, current) { if (!self.required) return true; if (!current || !_.startsWith(current, "wizard_plugin_corewizard_acl_") || self.setup()) { return true; } - showMessageDialog({ - title: gettext("Please set up Access Control"), - message: gettext("You haven't yet set up access control. You need to either setup a username and password and click \"Keep Access Control Enabled\" or click \"Disable Access Control\" before continuing") - }); + + self._showDecisionNeededDialog(); return false; }; - self.onWizardDetails = function(response) { - self.required = response && response.corewizard && response.corewizard.details && response.corewizard.details.acl && response.corewizard.details.acl.required; - }; + self.onBeforeWizardFinish = function () { + if (!self.required) return true; - self.onWizardFinish = function() { - if (!self.required) return; + if (self.setup()) return true; - if (!self.decision()) { - return "reload"; - } + self._showDecisionNeededDialog(); + return false; + }; + + self.onWizardDetails = function (response) { + self.required = + response && + response.corewizard && + response.corewizard.details && + response.corewizard.details.acl && + response.corewizard.details.acl.required; }; } @@ -106,17 +100,24 @@ $(function() { self.required = false; - self.onWizardDetails = function(response) { - self.required = response && response.corewizard && response.corewizard.details && response.corewizard.details.webcam && response.corewizard.details.webcam.required; + self.onWizardDetails = function (response) { + self.required = + response && + response.corewizard && + response.corewizard.details && + response.corewizard.details.webcam && + response.corewizard.details.webcam.required; }; - self.onWizardFinish = function() { + self.onWizardFinish = function () { if (!self.required) return; - if (self.settingsViewModel.webcam_streamUrl() - || (self.settingsViewModel.webcam_snapshotUrl() && self.settingsViewModel.webcam_ffmpegPath())) { + if ( + self.settingsViewModel.webcam_streamUrl() || + (self.settingsViewModel.webcam_snapshotUrl() && self.settingsViewModel.webcam_ffmpegPath()) + ) { return "reload"; } - } + }; } function CoreWizardServerCommandsViewModel(parameters) { @@ -136,19 +137,19 @@ $(function() { self.required = false; self.active = false; - self.enableOnlineCheck = function() { + self.enableOnlineCheck = function () { self.settingsViewModel.server_onlineCheck_enabled(true); self.decision(true); self._sendData(); }; - self.disableOnlineCheck = function() { + self.disableOnlineCheck = function () { self.settingsViewModel.server_onlineCheck_enabled(false); self.decision(false); self._sendData(); }; - self.onBeforeWizardTabChange = function(next, current) { + self.onBeforeWizardTabChange = function (next, current) { if (!self.required) return true; if (!current || !_.startsWith(current, "wizard_plugin_corewizard_onlinecheck_") || self.setup()) { @@ -159,7 +160,7 @@ $(function() { return false; }; - self.onBeforeWizardFinish = function() { + self.onBeforeWizardFinish = function () { if (!self.required) return true; if (self.setup()) { @@ -170,22 +171,25 @@ $(function() { return false; }; - self.onWizardPreventSettingsRefreshDialog = function() { - return self.active; - }; - - self.onWizardDetails = function(response) { - self.required = response && response.corewizard && response.corewizard.details && response.corewizard.details.onlinecheck && response.corewizard.details.onlinecheck.required; + self.onWizardDetails = function (response) { + self.required = + response && + response.corewizard && + response.corewizard.details && + response.corewizard.details.onlinecheck && + response.corewizard.details.onlinecheck.required; }; - self._showDecisionNeededDialog = function() { + self._showDecisionNeededDialog = function () { showMessageDialog({ title: gettext("Please set up the online connectivity check"), - message: gettext("You haven't yet decided on whether to enable or disable the online connectivity check. You need to either enable or disable it before continuing.") + message: gettext( + "You haven't yet decided on whether to enable or disable the online connectivity check. You need to either enable or disable it before continuing." + ) }); }; - self._sendData = function() { + self._sendData = function () { var data = { server: { onlineCheck: { @@ -198,13 +202,11 @@ $(function() { }; self.active = true; - self.settingsViewModel.saveData(data) - .done(function() { - self.setup(true); - self.active = false; - }); + self.settingsViewModel.saveData(data).done(function () { + self.setup(true); + self.active = false; + }); }; - } function CoreWizardPluginBlacklistViewModel(parameters) { @@ -218,19 +220,19 @@ $(function() { self.required = false; self.active = false; - self.enablePluginBlacklist = function() { + self.enablePluginBlacklist = function () { self.settingsViewModel.server_pluginBlacklist_enabled(true); self.decision(true); self._sendData(); }; - self.disablePluginBlacklist = function() { + self.disablePluginBlacklist = function () { self.settingsViewModel.server_pluginBlacklist_enabled(false); self.decision(false); self._sendData(); }; - self.onBeforeWizardTabChange = function(next, current) { + self.onBeforeWizardTabChange = function (next, current) { if (!self.required) return true; if (!current || !_.startsWith(current, "wizard_plugin_corewizard_pluginblacklist_") || self.setup()) { @@ -241,7 +243,7 @@ $(function() { return false; }; - self.onBeforeWizardFinish = function() { + self.onBeforeWizardFinish = function () { if (!self.required) return true; if (self.setup()) { @@ -252,22 +254,25 @@ $(function() { return false; }; - self.onWizardPreventSettingsRefreshDialog = function() { - return self.active; - }; - - self.onWizardDetails = function(response) { - self.required = response && response.corewizard && response.corewizard.details && response.corewizard.details.pluginblacklist && response.corewizard.details.pluginblacklist.required; + self.onWizardDetails = function (response) { + self.required = + response && + response.corewizard && + response.corewizard.details && + response.corewizard.details.pluginblacklist && + response.corewizard.details.pluginblacklist.required; }; - self._showDecisionNeededDialog = function() { + self._showDecisionNeededDialog = function () { showMessageDialog({ title: gettext("Please set up the plugin blacklist processing"), - message: gettext("You haven't yet decided on whether to enable or disable the plugin blacklist processing. You need to either enable or disable it before continuing.") + message: gettext( + "You haven't yet decided on whether to enable or disable the plugin blacklist processing. You need to either enable or disable it before continuing." + ) }); }; - self._sendData = function() { + self._sendData = function () { var data = { server: { pluginBlacklist: { @@ -277,13 +282,11 @@ $(function() { }; self.active = true; - self.settingsViewModel.saveData(data) - .done(function() { - self.setup(true); - self.active = false; - }); + self.settingsViewModel.saveData(data).done(function () { + self.setup(true); + self.active = false; + }); }; - } function CoreWizardPrinterProfileViewModel(parameters) { @@ -296,54 +299,66 @@ $(function() { self.editor = self.printerProfiles.createProfileEditor(); self.editorLoaded = ko.observable(false); - self.onWizardDetails = function(response) { - self.required = response && response.corewizard && response.corewizard.details && response.corewizard.details.printerprofile && response.corewizard.details.printerprofile.required; + self.onWizardDetails = function (response) { + self.required = + response && + response.corewizard && + response.corewizard.details && + response.corewizard.details.printerprofile && + response.corewizard.details.printerprofile.required; if (!self.required) return; - OctoPrint.printerprofiles.get("_default") - .done(function(data) { + OctoPrint.printerprofiles + .get("_default") + .done(function (data) { self.editor.fromProfileData(data); self.editorLoaded(true); }) - .fail(function() { + .fail(function () { self.editor.fromProfileData(); self.editorLoaded(true); }); }; - self.onWizardFinish = function() { + self.onWizardFinish = function () { if (!self.required) return; - OctoPrint.printerprofiles.update("_default", self.editor.toProfileData()) - .done(function() { - self.printerProfiles.requestData(); - }); + OctoPrint.printerprofiles.update("_default", self.editor.toProfileData()).done(function () { + self.printerProfiles.requestData(); + }); }; } - OCTOPRINT_VIEWMODELS.push({ - construct: CoreWizardAclViewModel, - dependencies: ["loginStateViewModel"], - elements: ["#wizard_plugin_corewizard_acl"] - }, { - construct: CoreWizardWebcamViewModel, - dependencies: ["settingsViewModel"], - elements: ["#wizard_plugin_corewizard_webcam"] - }, { - construct: CoreWizardServerCommandsViewModel, - dependencies: ["settingsViewModel"], - elements: ["#wizard_plugin_corewizard_servercommands"] - }, { - construct: CoreWizardOnlineCheckViewModel, - dependencies: ["settingsViewModel"], - elements: ["#wizard_plugin_corewizard_onlinecheck"] - }, { - construct: CoreWizardPluginBlacklistViewModel, - dependencies: ["settingsViewModel"], - elements: ["#wizard_plugin_corewizard_pluginblacklist"] - }, { - construct: CoreWizardPrinterProfileViewModel, - dependencies: ["printerProfilesViewModel"], - elements: ["#wizard_plugin_corewizard_printerprofile"] - }); + OCTOPRINT_VIEWMODELS.push( + { + construct: CoreWizardAclViewModel, + dependencies: ["loginStateViewModel"], + elements: ["#wizard_plugin_corewizard_acl"] + }, + { + construct: CoreWizardWebcamViewModel, + dependencies: ["settingsViewModel"], + elements: ["#wizard_plugin_corewizard_webcam"] + }, + { + construct: CoreWizardServerCommandsViewModel, + dependencies: ["settingsViewModel"], + elements: ["#wizard_plugin_corewizard_servercommands"] + }, + { + construct: CoreWizardOnlineCheckViewModel, + dependencies: ["settingsViewModel"], + elements: ["#wizard_plugin_corewizard_onlinecheck"] + }, + { + construct: CoreWizardPluginBlacklistViewModel, + dependencies: ["settingsViewModel"], + elements: ["#wizard_plugin_corewizard_pluginblacklist"] + }, + { + construct: CoreWizardPrinterProfileViewModel, + dependencies: ["printerProfilesViewModel"], + elements: ["#wizard_plugin_corewizard_printerprofile"] + } + ); }); diff --git a/src/octoprint/plugins/corewizard/subwizards.py b/src/octoprint/plugins/corewizard/subwizards.py index a41a4eca85..f7d38b5189 100644 --- a/src/octoprint/plugins/corewizard/subwizards.py +++ b/src/octoprint/plugins/corewizard/subwizards.py @@ -1,154 +1,176 @@ -# coding=utf-8 -from __future__ import absolute_import, division, print_function +from __future__ import absolute_import, division, print_function, unicode_literals -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' +__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html" __copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License" -import octoprint.plugin - -import sys import inspect -from flask.ext.babel import gettext +import sys + +from flask_babel import gettext + +import octoprint.plugin +from octoprint.access import ADMIN_GROUP, USER_GROUP +from octoprint.util import to_native_str # noinspection PyUnresolvedReferences,PyMethodMayBeStatic class ServerCommandsSubwizard(object): - def _is_servercommands_wizard_firstrunonly(self): - return True + def _is_servercommands_wizard_firstrunonly(self): + return True - def _is_servercommands_wizard_required(self): - system_shutdown_command = self._settings.global_get(["server", "commands", "systemShutdownCommand"]) - system_restart_command = self._settings.global_get(["server", "commands", "systemRestartCommand"]) - server_restart_command = self._settings.global_get(["server", "commands", "serverRestartCommand"]) + def _is_servercommands_wizard_required(self): + system_shutdown_command = self._settings.global_get( + ["server", "commands", "systemShutdownCommand"] + ) + system_restart_command = self._settings.global_get( + ["server", "commands", "systemRestartCommand"] + ) + server_restart_command = self._settings.global_get( + ["server", "commands", "serverRestartCommand"] + ) - return not (system_shutdown_command and system_restart_command and server_restart_command) + return not ( + system_shutdown_command and system_restart_command and server_restart_command + ) - def _get_servercommands_wizard_details(self): - return dict(required=self._is_servercommands_wizard_required()) + def _get_servercommands_wizard_details(self): + return {"required": self._is_servercommands_wizard_required()} - def _get_servercommands_wizard_name(self): - return gettext("Server Commands") + def _get_servercommands_wizard_name(self): + return gettext("Server Commands") # noinspection PyUnresolvedReferences,PyMethodMayBeStatic class WebcamSubwizard(object): - def _is_webcam_wizard_firstrunonly(self): - return True + def _is_webcam_wizard_firstrunonly(self): + return True - def _is_webcam_wizard_required(self): - webcam_snapshot_url = self._settings.global_get(["webcam", "snapshot"]) - webcam_stream_url = self._settings.global_get(["webcam", "stream"]) - ffmpeg_path = self._settings.global_get(["webcam", "ffmpeg"]) + def _is_webcam_wizard_required(self): + webcam_snapshot_url = self._settings.global_get(["webcam", "snapshot"]) + webcam_stream_url = self._settings.global_get(["webcam", "stream"]) + ffmpeg_path = self._settings.global_get(["webcam", "ffmpeg"]) - return not (webcam_snapshot_url and webcam_stream_url and ffmpeg_path) + return not (webcam_snapshot_url and webcam_stream_url and ffmpeg_path) - def _get_webcam_wizard_details(self): - return dict(required=self._is_webcam_wizard_required()) + def _get_webcam_wizard_details(self): + return {"required": self._is_webcam_wizard_required()} - def _get_webcam_wizard_name(self): - return gettext("Webcam & Timelapse") + def _get_webcam_wizard_name(self): + return gettext("Webcam & Timelapse") # noinspection PyUnresolvedReferences,PyMethodMayBeStatic class AclSubwizard(object): - def _is_acl_wizard_firstrunonly(self): - return True - - def _is_acl_wizard_required(self): - return self._user_manager.enabled and not self._user_manager.hasBeenCustomized() - - def _get_acl_wizard_details(self): - return dict(required=self._is_acl_wizard_required()) - - def _get_acl_wizard_name(self): - return gettext("Access Control") - - def _get_acl_additional_wizard_template_data(self): - return dict(mandatory=self._is_acl_wizard_required()) - - @octoprint.plugin.BlueprintPlugin.route("/acl", methods=["POST"]) - def acl_wizard_api(self): - from flask import request, abort - from octoprint.server.api import valid_boolean_trues, NO_CONTENT - - if not self._settings.global_get(["server", "firstRun"]) or self._user_manager.hasBeenCustomized(): - abort(404) - - data = request.values - if hasattr(request, "json") and request.json: - data = request.json - - if "ac" in data and data["ac"] in valid_boolean_trues and \ - "user" in data.keys() and "pass1" in data.keys() and \ - "pass2" in data.keys() and data["pass1"] == data["pass2"]: - # configure access control - self._settings.global_set_boolean(["accessControl", "enabled"], True) - self._user_manager.enable() - self._user_manager.addUser(data["user"], data["pass1"], True, ["user", "admin"], overwrite=True) - elif "ac" in data.keys() and not data["ac"] in valid_boolean_trues: - # disable access control - self._settings.global_set_boolean(["accessControl", "enabled"], False) - - octoprint.server.loginManager.anonymous_user = octoprint.users.DummyUser - octoprint.server.principals.identity_loaders.appendleft(octoprint.users.dummy_identity_loader) - - self._user_manager.disable() - self._settings.save() - return NO_CONTENT + def _is_acl_wizard_firstrunonly(self): + return False + + def _is_acl_wizard_required(self): + return not self._user_manager.has_been_customized() + + def _get_acl_wizard_details(self): + return {"required": self._is_acl_wizard_required()} + + def _get_acl_wizard_name(self): + return gettext("Access Control") + + def _get_acl_additional_wizard_template_data(self): + return {"mandatory": self._is_acl_wizard_required()} + + @octoprint.plugin.BlueprintPlugin.route("/acl", methods=["POST"]) + def acl_wizard_api(self): + from flask import abort, request + + from octoprint.server.api import NO_CONTENT + + if ( + not self._settings.global_get(["server", "firstRun"]) + and self._user_manager.has_been_customized() + ): + abort(404) + + data = request.get_json() + if data is None: + data = request.values + + if ( + "user" in data + and "pass1" in data + and "pass2" in data + and data["pass1"] == data["pass2"] + ): + # configure access control + self._user_manager.add_user( + data["user"], + data["pass1"], + True, + [], + [USER_GROUP, ADMIN_GROUP], + overwrite=True, + ) + self._settings.save() + return NO_CONTENT # noinspection PyUnresolvedReferences,PyMethodMayBeStatic class OnlineCheckSubwizard(object): - def _is_onlinecheck_wizard_firstrunonly(self): - return False + def _is_onlinecheck_wizard_firstrunonly(self): + return False - def _is_onlinecheck_wizard_required(self): - return self._settings.global_get(["server", "onlineCheck", "enabled"]) is None + def _is_onlinecheck_wizard_required(self): + return self._settings.global_get(["server", "onlineCheck", "enabled"]) is None - def _get_onlinecheck_wizard_details(self): - return dict(required=self._is_onlinecheck_wizard_required()) + def _get_onlinecheck_wizard_details(self): + return {"required": self._is_onlinecheck_wizard_required()} - def _get_onlinecheck_wizard_name(self): - return gettext("Online connectivity check") + def _get_onlinecheck_wizard_name(self): + return gettext("Online Connectivity Check") - def _get_onlinecheck_additional_wizard_template_data(self): - return dict(mandatory=self._is_onlinecheck_wizard_required()) + def _get_onlinecheck_additional_wizard_template_data(self): + return {"mandatory": self._is_onlinecheck_wizard_required()} # noinspection PyUnresolvedReferences,PyMethodMayBeStatic class PluginBlacklistSubwizard(object): - def _is_pluginblacklist_wizard_firstrunonly(self): - return False + def _is_pluginblacklist_wizard_firstrunonly(self): + return False - def _is_pluginblacklist_wizard_required(self): - return self._settings.global_get(["server", "pluginBlacklist", "enabled"]) is None + def _is_pluginblacklist_wizard_required(self): + return self._settings.global_get(["server", "pluginBlacklist", "enabled"]) is None - def _get_pluginblacklist_wizard_details(self): - return dict(required=self._is_pluginblacklist_wizard_required()) + def _get_pluginblacklist_wizard_details(self): + return {"required": self._is_pluginblacklist_wizard_required()} - def _get_pluginblacklist_wizard_name(self): - return gettext("Plugin blacklist") + def _get_pluginblacklist_wizard_name(self): + return gettext("Plugin Blacklist") - def _get_pluginblacklist_additional_wizard_template_data(self): - return dict(mandatory=self._is_pluginblacklist_wizard_required()) + def _get_pluginblacklist_additional_wizard_template_data(self): + return {"mandatory": self._is_pluginblacklist_wizard_required()} # noinspection PyUnresolvedReferences,PyMethodMayBeStatic class PrinterProfileSubwizard(object): - def _is_printerprofile_wizard_firstrunonly(self): - return True - - def _is_printerprofile_wizard_required(self): - return self._printer_profile_manager.is_default_unmodified() and self._printer_profile_manager.profile_count == 1 - - def _get_printerprofile_wizard_details(self): - return dict(required=self._is_printerprofile_wizard_required()) - - def _get_printerprofile_wizard_name(self): - return gettext("Default Printer Profile") - - -Subwizards = type("Subwizwards", - tuple(cls for clsname, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass) - if clsname.endswith("Subwizard")), - dict()) + def _is_printerprofile_wizard_firstrunonly(self): + return True + + def _is_printerprofile_wizard_required(self): + return ( + self._printer_profile_manager.is_default_unmodified() + and self._printer_profile_manager.profile_count == 1 + ) + + def _get_printerprofile_wizard_details(self): + return {"required": self._is_printerprofile_wizard_required()} + + def _get_printerprofile_wizard_name(self): + return gettext("Default Printer Profile") + + +Subwizards = type( + to_native_str("Subwizards"), + tuple( + cls + for clsname, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass) + if clsname.endswith("Subwizard") + ), + {}, +) diff --git a/src/octoprint/plugins/corewizard/templates/corewizard_acl_wizard.jinja2 b/src/octoprint/plugins/corewizard/templates/corewizard_acl_wizard.jinja2 index 58cd4bfa77..1537989d2c 100644 --- a/src/octoprint/plugins/corewizard/templates/corewizard_acl_wizard.jinja2 +++ b/src/octoprint/plugins/corewizard/templates/corewizard_acl_wizard.jinja2 @@ -1,20 +1,12 @@

{{ _('Access Control') }}

{% trans %}

- Please read the following, it is very important for your printer's health! -

-

- OctoPrint by default ships with Access Control enabled, meaning you won't be able to do anything with the - printer unless you login first as a configured user. This is to prevent strangers - possibly with - malicious intent - to gain access to your printer via the internet or another untrustworthy network - and using it in such a way that it is damaged or worse (i.e. causes a fire). -

-

- It looks like you haven't configured access control yet. Please set up a username and password for the - initial administrator account who will have full access to both the printer and OctoPrint's settings, then click - on "Keep Access Control Enabled": + It looks like you haven't configured access control yet, which is now mandatory. + Please set up a username and password for the + initial administrator account who will have full access to both the printer and + OctoPrint's settings:

{% endtrans %} -
+
@@ -34,26 +26,7 @@ {{ _('Passwords do not match') }}
+
-{% trans %}

- Note: In case that your OctoPrint installation is only accessible from within a trustworthy network and you don't - need Access Control for other reasons, you may alternatively disable Access Control. You should only - do this if you are absolutely certain that only people you know and trust will be able to connect to it. -

-

- Do NOT underestimate the risk of an unsecured access from the internet to your printer! -

{% endtrans %} - - - - diff --git a/src/octoprint/plugins/corewizard/templates/corewizard_onlinecheck_wizard.jinja2 b/src/octoprint/plugins/corewizard/templates/corewizard_onlinecheck_wizard.jinja2 index fa4522cd05..d8715540e9 100644 --- a/src/octoprint/plugins/corewizard/templates/corewizard_onlinecheck_wizard.jinja2 +++ b/src/octoprint/plugins/corewizard/templates/corewizard_onlinecheck_wizard.jinja2 @@ -11,7 +11,7 @@ OctoPrint comes preconfigured to perform the connectivity check every 15 minutes. You may change the value here. {% endtrans %}

-
+ {% include "snippets/settings/server/serverOnlineCheckInterval.jinja2" %}
@@ -21,20 +21,38 @@ trust and that has a high availability. {% endtrans %}

-
+ {% include "snippets/settings/server/serverOnlineCheckHost.jinja2" %} {% include "snippets/settings/server/serverOnlineCheckPort.jinja2" %} - {% include "snippets/settings/server/serverOnlineCheckTest.jinja2" %} + {% include "snippets/settings/server/serverOnlineCheckTestConnectivity.jinja2" %} +
+ +

{% trans %} + If you also want to check if name resolution works (strongly recommended), define a host name + to check name resolution against. If you don't know what to set here, the default value + (OctoPrint's domain) should work. If you don't want to perform regular resolution checks along + side with the general connectivity check, leave the field empty. +{% endtrans %}

+ +
+
+ {% include "snippets/settings/server/serverOnlineCheckName.jinja2" %} + {% include "snippets/settings/server/serverOnlineCheckTestResolution.jinja2" %} +

{% trans %} Finally, please decide on whether to enable or disable the connectivity check. You may change the configuration at - any time to a later date through Settings > Server right from within OctoPrint. + any time through Settings > Server right from within OctoPrint. {% endtrans %}