├── .gitignore ├── CHANGELOG.md ├── Images ├── freeze │ ├── freeze@2x.png │ └── metadata.json ├── python-nova │ └── python-nova@2x.png ├── reload │ ├── metadata.json │ └── reload@2x.png ├── sidebar-large │ └── sidebar-large@2x.png ├── sidebar-small │ └── sidebar-small@2x.png └── update │ ├── metadata.json │ └── update@2x.png ├── LICENSE ├── Queries ├── highlights.scm └── symbols.scm ├── README.md ├── Scripts ├── Config.js ├── Formatter.js ├── Linter.js ├── Notification.js ├── PackageSidebar.js ├── Pip.js ├── PyrightLanguageServer.js ├── PythonTaskAssistant.js ├── clean.sh ├── main.js └── utils.js ├── Syntaxes ├── PipRequirements.xml └── libtree-sitter-requirements.dylib ├── config.json ├── configWorkspace.json ├── extension.json ├── extension.png ├── extension@2x.png └── python-sidebar.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .nova 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Version 1.4.1 - 2025.02.21 2 | 3 | * Fixed an issue where `--stdin-filename` was not being passed to `ruff` (#10) 4 | 5 | 6 | ## Version 1.4.0 - 2025.01.30 7 | 8 | * Fixed an issue where imports were not sorted on save if linting errors exist in the file 9 | * **Initial support for uv!** 10 | * Use `uv` when available for freezing requirements (via `uv export`) 11 | * Use `uv` for the package sidebar (much faster to show outdated and audit) 12 | 13 | 14 | ## Version 1.3.1 - 2024.09.27 15 | 16 | * Minor bug fix when no `venvDirs` set 17 | 18 | 19 | ## Version 1.3.0 - 2024.05.10 20 | 21 | * Added a `Fix and Organize Imports (Workspace)` command 22 | * Search workspace root for virtual environments (`.venv`, etc.) 23 | * Recognize `requirements.lock` and `requirements-dev.lock` as requirements files 24 | * Updated to latest [tree-sitter-requirements](https://github.com/tree-sitter-grammars/tree-sitter-requirements) 25 | 26 | 27 | ## Version 1.2.1 - 2024.04.03 28 | 29 | * Added support for Pyright's `pyright.disableTaggedHints` setting 30 | 31 | 32 | ## Version 1.2 - 2023.12.15 33 | 34 | * New commands for fixing all violations or organizing all imports in your workspace 35 | * Support (and prefer) `ruff format` in addition to `black` 36 | * New workspace settings for fixing violations and organizing imports on editor save 37 | * Workspace cleanup was not properly removing .egg-info directories 38 | 39 | 40 | ## Version 1.1 - 2023.12.14 41 | 42 | * Task, command, and settings for cleaning up Python cache files and build artifacts (#4) 43 | * New setting for Pip's `--upgrade-strategy` option (#3) 44 | 45 | 46 | ## Version 1.0 47 | 48 | Initial release 49 | -------------------------------------------------------------------------------- /Images/freeze/freeze@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nova-python/Python.novaextension/ab36183a5259d60195d8384150e4a067051e38f4/Images/freeze/freeze@2x.png -------------------------------------------------------------------------------- /Images/freeze/metadata.json: -------------------------------------------------------------------------------- 1 | { "template": true } 2 | -------------------------------------------------------------------------------- /Images/python-nova/python-nova@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nova-python/Python.novaextension/ab36183a5259d60195d8384150e4a067051e38f4/Images/python-nova/python-nova@2x.png -------------------------------------------------------------------------------- /Images/reload/metadata.json: -------------------------------------------------------------------------------- 1 | { "template": true } 2 | -------------------------------------------------------------------------------- /Images/reload/reload@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nova-python/Python.novaextension/ab36183a5259d60195d8384150e4a067051e38f4/Images/reload/reload@2x.png -------------------------------------------------------------------------------- /Images/sidebar-large/sidebar-large@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nova-python/Python.novaextension/ab36183a5259d60195d8384150e4a067051e38f4/Images/sidebar-large/sidebar-large@2x.png -------------------------------------------------------------------------------- /Images/sidebar-small/sidebar-small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nova-python/Python.novaextension/ab36183a5259d60195d8384150e4a067051e38f4/Images/sidebar-small/sidebar-small@2x.png -------------------------------------------------------------------------------- /Images/update/metadata.json: -------------------------------------------------------------------------------- 1 | { "template": true } 2 | -------------------------------------------------------------------------------- /Images/update/update@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nova-python/Python.novaextension/ab36183a5259d60195d8384150e4a067051e38f4/Images/update/update@2x.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dan Watson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Queries/highlights.scm: -------------------------------------------------------------------------------- 1 | (package) @identifier.global 2 | (extras) @processing 3 | (comment) @comment 4 | (version_cmp) @operator 5 | (version) @value.number 6 | (quoted_string) @string 7 | (marker_spec) @declaration 8 | (env_var) @identifier.variable 9 | (option) @processing 10 | (argument) @identifier.argument 11 | (url) @markup.link 12 | -------------------------------------------------------------------------------- /Queries/symbols.scm: -------------------------------------------------------------------------------- 1 | (requirement 2 | (package) @name 3 | (#set! role tag-link) 4 | ) @subtree 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 🔋 A batteries-included **Python** extension for [Nova](https://nova.app)! 2 | 3 | 4 | ## What's in the box? 5 | 6 | * Integration with Microsoft's [Pyright language server](https://microsoft.github.io/pyright/#/) 7 | * Code formatting using [Ruff](https://docs.astral.sh/ruff/) or [Black](https://black.readthedocs.io/en/stable/) 8 | * Linting (and import organization) with [Ruff](https://docs.astral.sh/ruff/) 9 | * A Virtual Environment task for running a script or Python module as though the project's virtual environment is active 10 | * A Cleanup task for clearing out Python cache files, build artifacts, and other files/directories 11 | * A tree-sitter based syntax for `requirements.txt` files, based off of [tree-sitter-requirements](https://github.com/tree-sitter-grammars/tree-sitter-requirements) 12 | * A sidebar showing all packages installed in your configured Python environment, along with any outdated versions, and optionally any known vulnerabilities using [pip-audit](https://github.com/pypa/pip-audit) 13 | 14 | ![Python Sidebar](https://github.com/nova-python/Python.novaextension/raw/main/python-sidebar.png) 15 | 16 | 17 | ## Requirements 18 | 19 | If you use [Homebrew](https://brew.sh), the easiest way to get started is: 20 | 21 | `brew install pyright ruff pip-audit` 22 | 23 | This extension will try to find tools installed on your `PATH`, so installing into your virtual environment or another location should work, as well. 24 | 25 | 26 | ## Acknowledgements 27 | 28 | This project drew inspiration (and code!) from many others that came before it: 29 | 30 | * [Pyreet](https://codeberg.org/rv/nova-pyreet) 31 | * [Pyright for Nova](https://github.com/belcar-s/nova-pyright) 32 | * [Black Nova Extension](https://github.com/Aeron/Black.novaextension) and [Ruff Nova Extension](https://github.com/Aeron/Ruff.novaextension) 33 | 34 | 35 | ## Attributions 36 | 37 | * [Snowflake icons created by Good Ware - Flaticon](https://www.flaticon.com/free-icons/snowflake) 38 | * [Up arrow icons created by Ayub Irawan - Flaticon](https://www.flaticon.com/free-icons/up-arrow) 39 | * [Python icon by Anja van Staden](https://iconduck.com/icons/85785/python) 40 | -------------------------------------------------------------------------------- /Scripts/Config.js: -------------------------------------------------------------------------------- 1 | class Config { 2 | constructor(prefix = null, defaultSuffix = "default") { 3 | this.prefix = prefix || nova.extension.identifier; 4 | this.defaultSuffix = defaultSuffix; 5 | } 6 | 7 | get(name, coerce, defaultValue = null) { 8 | const qname = this.prefix + "." + name; 9 | // First try the workspace config 10 | const workspaceValue = nova.workspace.config.get(qname, coerce); 11 | if (workspaceValue !== null) { 12 | return workspaceValue; 13 | } 14 | // Then look in the extension config 15 | const globalValue = nova.config.get(qname, coerce); 16 | if (globalValue !== null) { 17 | return globalValue; 18 | } 19 | // Look for .default in the extension config 20 | const dname = qname + "." + this.defaultSuffix; 21 | const globalDefault = nova.config.get(dname, coerce); 22 | if (globalDefault !== null) { 23 | return globalDefault; 24 | } 25 | // Fall back to the passed in default value 26 | return defaultValue; 27 | } 28 | 29 | set(name, value) { 30 | nova.workspace.config.set(this.prefix + "." + name, value); 31 | } 32 | 33 | remove(name) { 34 | nova.workspace.config.remove(this.prefix + "." + name); 35 | } 36 | } 37 | 38 | module.exports = Config; 39 | -------------------------------------------------------------------------------- /Scripts/Formatter.js: -------------------------------------------------------------------------------- 1 | const utils = require("utils.js"); 2 | 3 | class Formatter { 4 | constructor(config) { 5 | this.config = config; 6 | } 7 | 8 | activate() { 9 | nova.subscriptions.add( 10 | nova.workspace.onDidAddTextEditor((editor) => { 11 | editor.onWillSave(this.maybeFormat, this); 12 | }) 13 | ); 14 | } 15 | 16 | deactivate() {} 17 | 18 | argsForCommand(cmd, filename, directory = null) { 19 | const executable = this.config.get("formatterPath", "string"); 20 | const extraArgs = this.config.get("formatterArgs", "array", []); 21 | const fileArgs = filename ? ["--stdin-filename", filename] : []; 22 | const target = directory || "-"; 23 | 24 | if (cmd.endsWith("ruff")) { 25 | return ["format", "--quiet", ...extraArgs, ...fileArgs, target]; 26 | } else if (cmd.endswith("black")) { 27 | return ["--quiet", ...extraArgs, ...fileArgs, target]; 28 | } 29 | 30 | return extraArgs; 31 | } 32 | 33 | run(stdin, filename, directory = null) { 34 | const executable = this.config.get("formatterPath", "string"); 35 | const opts = { stdin: stdin }; 36 | const self = this; 37 | 38 | return utils.resolvePath(["ruff", "black"], executable).then((cmd) => { 39 | const finalArgs = self.argsForCommand(cmd, filename, directory); 40 | return utils.run(cmd, opts, ...finalArgs); 41 | }); 42 | } 43 | 44 | format(editor) { 45 | if (editor.document.isEmpty) { 46 | return; 47 | } 48 | 49 | const textRange = new Range(0, editor.document.length); 50 | const content = editor.document.getTextInRange(textRange); 51 | 52 | return this.run(content, editor.document.path).then((result) => { 53 | if (result.success) { 54 | const formattedContent = result.stdout.join(""); 55 | return editor.edit((edit) => { 56 | if (formattedContent !== content) { 57 | edit.replace( 58 | textRange, 59 | formattedContent, 60 | InsertTextFormat.PlainText 61 | ); 62 | } 63 | }); 64 | } 65 | }); 66 | } 67 | 68 | maybeFormat(editor) { 69 | if (!this.config.get("formatOnSave", "boolean")) return; 70 | if (editor.document.syntax !== "python") return; 71 | return this.format(editor); 72 | } 73 | 74 | formatWorkspace(workspace) { 75 | return this.run(null, null, workspace.path); 76 | } 77 | } 78 | 79 | module.exports = Formatter; 80 | -------------------------------------------------------------------------------- /Scripts/Linter.js: -------------------------------------------------------------------------------- 1 | const utils = require("utils.js"); 2 | 3 | const LinterAction = { 4 | Check: "check", 5 | Fix: "fix", 6 | Organize: "organize", 7 | FixAndOrganize: "fixAndOrganize", 8 | }; 9 | 10 | class Linter { 11 | constructor(config) { 12 | this.config = config; 13 | this.issues = null; 14 | this.listener = null; 15 | this.assistant = null; 16 | } 17 | 18 | activate() { 19 | nova.subscriptions.add( 20 | nova.workspace.onDidAddTextEditor((editor) => { 21 | editor.onWillSave(this.maybeFix, this); 22 | }) 23 | ); 24 | 25 | this.issues = new IssueCollection(); 26 | this.listener = nova.workspace.config.observe( 27 | "python.linterCheckMode", 28 | (mode) => { 29 | if (mode == "onSave" || mode == "onChange") { 30 | // Automatic check modes. 31 | if (this.assistant) this.assistant.dispose(); 32 | this.assistant = nova.assistants.registerIssueAssistant( 33 | "python", 34 | this, 35 | { event: mode } 36 | ); 37 | } else { 38 | // Manual check mode. 39 | if (this.assistant) { 40 | this.assistant.dispose(); 41 | this.assistant = null; 42 | } 43 | this.issues.clear(); 44 | } 45 | } 46 | ); 47 | } 48 | 49 | deactivate() { 50 | if (this.assistant) { 51 | this.assistant.dispose(); 52 | this.assistant = null; 53 | } 54 | this.listener.dispose(); 55 | this.listener = null; 56 | this.issues.dispose(); 57 | this.issues = null; 58 | } 59 | 60 | argsForCommand(cmd, action, directory, filename) { 61 | const userArgs = this.config.get("linterArgs", "array", []); 62 | const finalArgs = ["check", "--quiet", "--exit-zero", ...userArgs]; 63 | if (action == LinterAction.FixAndOrganize) { 64 | finalArgs.push("--extend-select", "I", "--fix"); 65 | } else if (action == LinterAction.Fix) { 66 | finalArgs.push("--fix"); 67 | } else if (action == LinterAction.Organize) { 68 | finalArgs.push("--select", "I", "--fix"); 69 | } else { 70 | finalArgs.push("--output-format", "github"); 71 | } 72 | if (filename) { 73 | finalArgs.push("--stdin-filename", filename); 74 | } 75 | finalArgs.push(directory || "-"); 76 | return finalArgs; 77 | } 78 | 79 | run(stdin, action = LinterAction.Check, directory = null, filename = null) { 80 | const executable = this.config.get("linterPath", "string"); 81 | const opts = { stdin: stdin }; 82 | const self = this; 83 | 84 | return utils.resolvePath(["ruff"], executable).then((cmd) => { 85 | const finalArgs = self.argsForCommand(cmd, action, directory, filename); 86 | return utils.run(cmd, opts, ...finalArgs); 87 | }); 88 | } 89 | 90 | check(editor) { 91 | if (editor.document.isEmpty) { 92 | return; 93 | } 94 | 95 | const textRange = new Range(0, editor.document.length); 96 | const content = editor.document.getTextInRange(textRange); 97 | 98 | return this.run(content, LinterAction.Check, null, editor.document.path).then( 99 | (result) => { 100 | const parser = new IssueParser("ruff"); 101 | for (const line of result.stdout) { 102 | parser.pushLine(line); 103 | } 104 | return parser.issues; 105 | } 106 | ); 107 | } 108 | 109 | fix(editor, action = LinterAction.Fix) { 110 | if (editor.document.isEmpty) { 111 | return; 112 | } 113 | 114 | const textRange = new Range(0, editor.document.length); 115 | const content = editor.document.getTextInRange(textRange); 116 | 117 | return this.run(content, action, null, editor.document.path).then((result) => { 118 | if (result.success) { 119 | const newContent = result.stdout.join(""); 120 | return editor.edit((edit) => { 121 | if (newContent !== content) { 122 | edit.replace(textRange, newContent, InsertTextFormat.PlainText); 123 | } 124 | }); 125 | } 126 | }); 127 | } 128 | 129 | fixWorkspace(workspace) { 130 | return this.run(null, LinterAction.Fix, workspace.path); 131 | } 132 | 133 | organize(editor) { 134 | return this.fix(editor, LinterAction.Organize); 135 | } 136 | 137 | organizeWorkspace(workspace) { 138 | return this.run(null, LinterAction.Organize, workspace.path); 139 | } 140 | 141 | fixOrganizeWorkspace(workspace) { 142 | return this.run(null, LinterAction.FixAndOrganize, workspace.path); 143 | } 144 | 145 | manualCheck(editor) { 146 | if (this.assistant) return; 147 | this.check(editor).then((issues) => { 148 | this.issues.set(editor.document.uri, issues); 149 | }); 150 | } 151 | 152 | provideIssues(editor) { 153 | this.issues.clear(); 154 | return this.check(editor); 155 | } 156 | 157 | maybeFix(editor) { 158 | if (editor.document.syntax !== "python") return; 159 | 160 | const shouldFix = this.config.get("fixOnSave", "boolean", false); 161 | const shouldOrganize = this.config.get("organizeOnSave", "boolean", false); 162 | 163 | if (shouldFix && shouldOrganize) { 164 | return this.fix(editor, LinterAction.FixAndOrganize); 165 | } else if (shouldFix) { 166 | return this.fix(editor, LinterAction.Fix); 167 | } else if (shouldOrganize) { 168 | return this.fix(editor, LinterAction.Organize); 169 | } 170 | } 171 | } 172 | 173 | module.exports = Linter; 174 | -------------------------------------------------------------------------------- /Scripts/Notification.js: -------------------------------------------------------------------------------- 1 | class Notification { 2 | constructor(body, title = null, id = null) { 3 | this.id = id || nova.crypto.randomUUID(); 4 | 5 | this.request = new NotificationRequest(this.id); 6 | this.request.title = title || nova.extension.name; 7 | this.request.body = body; 8 | 9 | this.actionTitles = []; 10 | this.actions = []; 11 | } 12 | 13 | input(defaultText, placeholder = "", type = "input") { 14 | this.request.type = type; 15 | this.request.textInputValue = defaultText; 16 | this.request.textInputPlaceholder = placeholder; 17 | return this; 18 | } 19 | 20 | action(title, callback) { 21 | this.actionTitles.push(title); 22 | this.actions.push(callback); 23 | return this; 24 | } 25 | 26 | show() { 27 | this.request.actions = this.actionTitles; 28 | 29 | nova.notifications.add(this.request).then((reply) => { 30 | if (reply.actionIdx) { 31 | this.actions[reply.actionIdx](reply); 32 | } 33 | }); 34 | 35 | return this; 36 | } 37 | 38 | dismiss() { 39 | nova.notifications.cancel(this.id); 40 | } 41 | 42 | static error(msg) { 43 | nova.workspace.showErrorMessage(msg); 44 | //return new Notification(msg).show(); 45 | } 46 | } 47 | 48 | module.exports = Notification; 49 | -------------------------------------------------------------------------------- /Scripts/PackageSidebar.js: -------------------------------------------------------------------------------- 1 | const Notification = require("Notification.js"); 2 | 3 | class PackageDataProvider { 4 | constructor(pip) { 5 | this.pip = pip; 6 | this.packages = []; 7 | } 8 | 9 | reload() { 10 | return Promise.all([ 11 | this.pip.list(), 12 | this.pip.outdated(), 13 | this.pip.audit(), 14 | ]).then(([packages, outdated, vulns]) => { 15 | for (const i in packages) { 16 | packages[i].latest_version = outdated[packages[i].name]; 17 | packages[i].vulnerabilities = vulns[packages[i].name]; 18 | } 19 | this.packages = packages; 20 | return packages; 21 | }); 22 | } 23 | 24 | getChildren(element) { 25 | if (element) { 26 | return element.vulnerabilities || []; 27 | } else { 28 | return this.packages; 29 | } 30 | } 31 | 32 | getTreeItem(element) { 33 | if (element.id) { 34 | // Vulnerability 35 | let item = new TreeItem(element.id); 36 | if (element.fix_versions.length > 0) { 37 | item.descriptiveText = "(" + element.fix_versions.join(", ") + ")"; 38 | } 39 | item.image = "__symbol.bookmark"; 40 | item.tooltip = element.description; 41 | return item; 42 | } 43 | 44 | // Top level package 45 | let item = new TreeItem(element.name); 46 | item.contextValue = element.name; 47 | if (element.latest_version) { 48 | item.descriptiveText = element.version + " → " + element.latest_version; 49 | item.color = Color.rgb(0.8, 0.8, 0); 50 | } else { 51 | item.descriptiveText = element.version; 52 | item.color = Color.rgb(0, 0.5, 0); 53 | } 54 | 55 | if (element.vulnerabilities) { 56 | item.color = Color.rgb(1, 0, 0); 57 | item.collapsibleState = TreeItemCollapsibleState.Expanded; 58 | } 59 | 60 | return item; 61 | } 62 | } 63 | 64 | class PackageSidebar { 65 | constructor(pip) { 66 | this.pip = pip; 67 | this.data = new PackageDataProvider(pip); 68 | this.tree = null; 69 | } 70 | 71 | activate() { 72 | this.tree = new TreeView("python.packages.installed", { 73 | dataProvider: this.data, 74 | }); 75 | nova.subscriptions.add(this.tree); 76 | } 77 | 78 | deactivate() { 79 | this.tree = null; 80 | } 81 | 82 | refresh() { 83 | let note = new Notification("Refreshing package list...").show(); 84 | let tree = this.tree; 85 | this.data.reload().then( 86 | (packages) => { 87 | tree.reload(); 88 | note.dismiss(); 89 | }, 90 | (error) => { 91 | note.dismiss(); 92 | Notification.error(error); 93 | } 94 | ); 95 | } 96 | 97 | selectedPackages() { 98 | return this.tree ? this.tree.selection.map((item) => item.name) : []; 99 | } 100 | } 101 | 102 | module.exports = PackageSidebar; 103 | -------------------------------------------------------------------------------- /Scripts/Pip.js: -------------------------------------------------------------------------------- 1 | const utils = require("utils.js"); 2 | 3 | class Pip { 4 | constructor(config) { 5 | this.config = config; 6 | } 7 | 8 | _useUV() { 9 | let uvLockPath = nova.path.join(nova.workspace.path, "uv.lock"); 10 | let pyprojectPath = nova.path.join(nova.workspace.path, "pyproject.toml"); 11 | if (nova.fs.stat(uvLockPath) && nova.fs.stat(pyprojectPath)) return true; 12 | return false; 13 | } 14 | 15 | install(packages, upgrade = false) { 16 | if (this._useUV()) { 17 | nova.workspace.showErrorMessage("Package management not yet supported via uv."); 18 | return Promise.reject({}); 19 | } 20 | 21 | const strategy = this.config.get("pipUpgradeStrategy", "string"); 22 | const extraArgs = upgrade ? ["--upgrade", "--upgrade-strategy", strategy] : []; 23 | return utils.run( 24 | this.config.get("pythonPath"), 25 | "-m", 26 | "pip", 27 | "install", 28 | "--no-input", 29 | "--progress-bar", 30 | "off", 31 | ...extraArgs, 32 | ...packages 33 | ); 34 | } 35 | 36 | upgrade(packages) { 37 | return this.install(packages, true); 38 | } 39 | 40 | uninstall(packages) { 41 | if (this._useUV()) { 42 | nova.workspace.showErrorMessage("Package management not yet supported via uv."); 43 | return Promise.reject({}); 44 | } 45 | 46 | return utils.run( 47 | this.config.get("pythonPath"), 48 | "-m", 49 | "pip", 50 | "uninstall", 51 | "--no-input", 52 | "--yes", 53 | ...packages 54 | ); 55 | } 56 | 57 | freeze() { 58 | if (this._useUV()) { 59 | return utils.resolvePath(["uv"], null).then((uvPath) => { 60 | return utils 61 | .run(uvPath, "export", "--no-hashes", "--no-dev") 62 | .then((result) => result.stdout.map((p) => p.trim())); 63 | }); 64 | } 65 | else { 66 | return utils 67 | .run(this.config.get("pythonPath"), "-m", "pip", "freeze") 68 | .then((result) => result.stdout.map((p) => p.trim())); 69 | } 70 | } 71 | 72 | list() { 73 | if (this._useUV()) { 74 | // TODO: allow setting uv path in config 75 | return utils.resolvePath(["uv"], null).then((uvPath) => { 76 | return utils 77 | .run(uvPath, "pip", "list", "--format", "json") 78 | .then((result) => JSON.parse(result.stdout.join(""))); 79 | }); 80 | } else { 81 | return utils 82 | .run( 83 | this.config.get("pythonPath"), 84 | "-m", 85 | "pip", 86 | "list", 87 | "--format", 88 | "json" 89 | ) 90 | .then((result) => JSON.parse(result.stdout.join(""))); 91 | } 92 | } 93 | 94 | outdated() { 95 | if (this._useUV()) { 96 | return utils.resolvePath(["uv"], null).then((uvPath) => { 97 | return utils 98 | .run(uvPath, "pip", "list", "--outdated", "--format", "json") 99 | .then((result) => { 100 | let packages = JSON.parse(result.stdout.join("")); 101 | var outdated = {}; 102 | for (const p of packages) { 103 | outdated[p.name] = p.latest_version; 104 | } 105 | return outdated; 106 | }); 107 | }); 108 | } else { 109 | return utils 110 | .run( 111 | this.config.get("pythonPath"), 112 | "-m", 113 | "pip", 114 | "list", 115 | "--outdated", 116 | "--format", 117 | "json" 118 | ) 119 | .then((result) => { 120 | let packages = JSON.parse(result.stdout.join("")); 121 | var outdated = {}; 122 | for (const p of packages) { 123 | outdated[p.name] = p.latest_version; 124 | } 125 | return outdated; 126 | }); 127 | } 128 | } 129 | 130 | audit() { 131 | // pip-audit --disable-pip --format json --progress-spinner off -r <(uv export --quiet --no-emit-project) 132 | let pipAuditPath = this.config.get("pipAuditPath", "string"); 133 | if (this._useUV()) { 134 | return utils.resolvePath(["pip-audit"], pipAuditPath).then((auditPath) => { 135 | return utils.resolvePath(["uv"], null).then((uvPath) => { 136 | let tmpRequirements = nova.path.join( 137 | nova.fs.tempdir, 138 | "requirements.txt" 139 | ); 140 | console.log(tmpRequirements); 141 | return utils 142 | .run( 143 | uvPath, 144 | "export", 145 | "--quiet", 146 | "--no-emit-project", 147 | "--no-hashes", 148 | "-o", 149 | tmpRequirements 150 | ) 151 | .then((result) => { 152 | return utils 153 | .run( 154 | auditPath, 155 | "--disable-pip", 156 | "--skip-editable", 157 | "--no-deps", 158 | "--format", 159 | "json", 160 | "--progress-spinner", 161 | "off", 162 | "-r", 163 | tmpRequirements 164 | ) 165 | .then((result) => { 166 | let vulnerabilities = {}; 167 | let data = JSON.parse(result.stdout.join("")); 168 | for (const p of data.dependencies) { 169 | if (p.vulns && p.vulns.length > 0) { 170 | vulnerabilities[p.name] = p.vulns; 171 | } 172 | } 173 | return vulnerabilities; 174 | }); 175 | }); 176 | }); 177 | }); 178 | } else { 179 | let pythonPath = this.config.get("pythonPath"); 180 | return utils.resolvePath(["pip-audit"], pipAuditPath).then( 181 | (cmd) => { 182 | return utils 183 | .run( 184 | cmd, 185 | { 186 | env: { 187 | PIPAPI_PYTHON_LOCATION: pythonPath, 188 | }, 189 | }, 190 | "--skip-editable", 191 | "--format", 192 | "json", 193 | "--progress-spinner", 194 | "off" 195 | ) 196 | .then((result) => { 197 | let vulnerabilities = {}; 198 | let data = JSON.parse(result.stdout.join("")); 199 | for (const p of data.dependencies) { 200 | if (p.vulns.length > 0) { 201 | vulnerabilities[p.name] = p.vulns; 202 | } 203 | } 204 | return vulnerabilities; 205 | }); 206 | }, 207 | (reason) => { 208 | console.warn("Skipping pip-audit check:", reason); 209 | return Promise.resolve([]); 210 | } 211 | ); 212 | } 213 | } 214 | } 215 | 216 | module.exports = Pip; 217 | -------------------------------------------------------------------------------- /Scripts/PyrightLanguageServer.js: -------------------------------------------------------------------------------- 1 | const RELOAD_PREFS = new Set([ 2 | // Our extension 3 | "python.pyrightPath", 4 | "python.pyrightEnabled", 5 | // Pyright config names 6 | "python.pythonPath", 7 | "python.analysis.diagnosticMode", 8 | "python.analysis.typeCheckingMode", 9 | "python.analysis.stubPath", 10 | "python.analysis.extraPaths", 11 | "pyright.disableTaggedHints", 12 | ]); 13 | 14 | class PyrightLanguageServer { 15 | constructor(config) { 16 | this.config = config; 17 | this.languageClient = null; 18 | 19 | for (const pref of RELOAD_PREFS) { 20 | nova.workspace.config.onDidChange(pref, (newValue, oldValue) => { 21 | if (oldValue !== newValue) { 22 | this.start(); 23 | } 24 | }); 25 | } 26 | } 27 | 28 | activate() { 29 | this.start(); 30 | } 31 | 32 | deactivate() { 33 | this.stop(); 34 | } 35 | 36 | start() { 37 | this.stop(); 38 | 39 | if (!this.config.get("pyrightEnabled", "boolean", true)) { 40 | return; 41 | } 42 | 43 | const path = this.config.get( 44 | "pyrightPath", 45 | "string", 46 | "/opt/homebrew/bin/pyright-langserver" 47 | ); 48 | 49 | var client = new LanguageClient( 50 | "pyright", 51 | "Pyright", 52 | { 53 | path: path, 54 | args: ["--stdio"], 55 | }, 56 | { 57 | syntaxes: ["python"], 58 | debug: false, 59 | } 60 | ); 61 | 62 | try { 63 | client.start(); 64 | nova.subscriptions.add(client); 65 | this.languageClient = client; 66 | setTimeout(() => { 67 | client.sendNotification("workspace/didChangeConfiguration", {}); 68 | }, 250); 69 | } catch (err) { 70 | if (nova.inDevMode()) { 71 | console.error(err); 72 | } 73 | } 74 | } 75 | 76 | stop() { 77 | if (this.languageClient) { 78 | this.languageClient.stop(); 79 | nova.subscriptions.remove(this.languageClient); 80 | this.languageClient = null; 81 | } 82 | } 83 | } 84 | 85 | module.exports = PyrightLanguageServer; 86 | -------------------------------------------------------------------------------- /Scripts/PythonTaskAssistant.js: -------------------------------------------------------------------------------- 1 | const utils = require("utils.js"); 2 | 3 | class PythonTaskAssistant { 4 | constructor(config) { 5 | this.config = config; 6 | } 7 | 8 | resolveTaskAction(context) { 9 | if (context.data.name == "run") { 10 | let pythonPath = this.config.get("pythonPath", "string"); 11 | if (!pythonPath) { 12 | throw new Error("No virtual environment set!"); 13 | } 14 | 15 | let script = context.config.get("script", "string"); 16 | let pythonModule = context.config.get("module", "string"); 17 | let args = context.config.get("args", "array") || []; 18 | let workdir = context.config.get("workdir", "string"); 19 | 20 | if (!script && !pythonModule) { 21 | throw new Error( 22 | "No Python script or module has been set for the task." 23 | ); 24 | } 25 | 26 | if (script) { 27 | let scriptPath = nova.path.join(nova.workspace.path, script); 28 | return new TaskProcessAction(scriptPath, { 29 | args: args, 30 | env: utils.activatedEnv({ 31 | PYTHONUNBUFFERED: "1", 32 | }), 33 | cwd: workdir || nova.workspace.path, 34 | }); 35 | } else { 36 | return new TaskProcessAction(pythonPath, { 37 | args: ["-m", pythonModule, ...args], 38 | env: utils.activatedEnv({ 39 | PYTHONUNBUFFERED: "1", 40 | }), 41 | cwd: workdir || nova.workspace.path, 42 | }); 43 | } 44 | } else if (context.data.name == "cleanup") { 45 | let cmd = nova.path.join(nova.extension.path, "Scripts", "clean.sh"); 46 | 47 | let cleanCache = context.config.get("python.cleanupCacheFiles", "boolean"); 48 | let cleanBuild = context.config.get("python.cleanupBuildDirs", "boolean"); 49 | let cleanExtra = 50 | context.config.get("python.cleanupExtras", "pathArray") || []; 51 | 52 | return new TaskProcessAction(cmd, { 53 | args: [nova.workspace.path], 54 | env: { 55 | CLEAN_CACHE_FILES: cleanCache ? "1" : "0", 56 | CLEAN_BUILD_ARTIFACTS: cleanBuild ? "1" : "0", 57 | CLEAN_EXTRAS: cleanExtra.join(";"), 58 | }, 59 | }); 60 | } 61 | 62 | return null; 63 | } 64 | } 65 | 66 | module.exports = PythonTaskAssistant; 67 | -------------------------------------------------------------------------------- /Scripts/clean.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Cleaning workspace: $1" 4 | 5 | if [ "$CLEAN_CACHE_FILES" = "1" ] 6 | then 7 | echo " * cleaning cache files" 8 | find $1 -type d -name '__pycache__' -print0 | xargs -0 rm -r 9 | find $1 -type f -name '*.pyc' -print0 | xargs -0 rm 10 | fi 11 | 12 | if [ "$CLEAN_BUILD_ARTIFACTS" = "1" ] 13 | then 14 | echo " * cleaning build artifacts" 15 | rm -rf "$1/dist" "$1/build" "$1"/*.egg-info 16 | fi 17 | 18 | if [ "$CLEAN_EXTRAS" != "" ] 19 | then 20 | echo " * cleaning extra files/directories" 21 | IFS=';' 22 | for name in $CLEAN_EXTRAS; do 23 | echo " * $name" 24 | rm -rf "$1/$name" 25 | done 26 | fi 27 | -------------------------------------------------------------------------------- /Scripts/main.js: -------------------------------------------------------------------------------- 1 | const Notification = require("Notification.js"); 2 | const PyrightLanguageServer = require("PyrightLanguageServer.js"); 3 | const Formatter = require("Formatter.js"); 4 | const Linter = require("Linter.js"); 5 | const Config = require("Config.js"); 6 | const Pip = require("Pip.js"); 7 | const PackageSidebar = require("PackageSidebar.js"); 8 | const PythonTaskAssistant = require("PythonTaskAssistant.js"); 9 | const utils = require("utils.js"); 10 | 11 | // Read from workspace, extension, and .default named configs. 12 | const config = new Config("python"); 13 | 14 | // Encapsulation of pip commands. 15 | const pip = new Pip(config); 16 | 17 | // Components of the extension, each with activate/deactivate methods. 18 | const langserver = new PyrightLanguageServer(config); 19 | const formatter = new Formatter(config); 20 | const linter = new Linter(config); 21 | const sidebar = new PackageSidebar(pip); 22 | 23 | exports.activate = function () { 24 | // If the built-in interpreter is set, and pythonPath is not, copy it over. 25 | const pythonPath = config.get("pythonPath", "string"); 26 | const interpreter = nova.workspace.config.get("python.interpreter", "string"); 27 | if (interpreter && !pythonPath) { 28 | console.info("Copying python.interpreter to pythonPath -", interpreter); 29 | config.set("pythonPath", interpreter); 30 | } 31 | 32 | langserver.activate(); 33 | sidebar.activate(); 34 | // Activate the linter first, so on-save fixes happen before formatting. 35 | linter.activate(); 36 | formatter.activate(); 37 | }; 38 | 39 | exports.deactivate = function () { 40 | langserver.deactivate(); 41 | sidebar.deactivate(); 42 | linter.deactivate(); 43 | formatter.deactivate(); 44 | }; 45 | 46 | nova.commands.register("python.copyInterpreter", (workspace) => { 47 | const pythonPath = config.get("pythonPath", "string"); 48 | if (pythonPath) { 49 | console.info("Copying pythonPath to python.interpreter -", pythonPath); 50 | nova.workspace.config.set("python.interpreter", pythonPath); 51 | } 52 | }); 53 | 54 | nova.commands.register("python.resolveEnvs", (workspace) => { 55 | let venvDirs = config.get("venvDirs", "array") || []; 56 | let promises = []; 57 | 58 | if (config.get("venvSearchWorkspace", "boolean", true)) { 59 | venvDirs.unshift(workspace.path); 60 | } 61 | 62 | if (!venvDirs) return []; 63 | 64 | for (const dir of venvDirs) { 65 | for (const item of nova.fs.listdir(dir)) { 66 | let venvDir = nova.path.expanduser(nova.path.join(dir, item)); 67 | promises.push(utils.checkEnv(venvDir)); 68 | } 69 | } 70 | 71 | return Promise.allSettled(promises).then((iterable) => { 72 | return iterable 73 | .filter((x) => x.status == "fulfilled") 74 | .map((x) => x.value) 75 | .sort((a, b) => a[1] > b[1]); 76 | }); 77 | }); 78 | 79 | nova.commands.register("python.reloadPackages", (workspace) => { 80 | sidebar.refresh(); 81 | }); 82 | 83 | nova.commands.register("python.pipFreeze", (workspace) => { 84 | let filename = config.get("pipRequirements", "string", "requirements.txt"); 85 | let path = nova.path.join(nova.workspace.path, filename); 86 | let exclude = new Set(config.get("pipExclude", "array") || []); 87 | let note = new Notification(`Freezing ${filename}`).show(); 88 | pip.freeze() 89 | .then((packages) => { 90 | let resolvedPackages = packages.filter( 91 | (p) => !exclude.has(p.split("=")[0]) 92 | ); 93 | let file = nova.fs.open(path, "w"); 94 | file.write(resolvedPackages.join("\n")); 95 | file.write("\n"); 96 | file.close(); 97 | note.dismiss(); 98 | }) 99 | .catch((msg) => { 100 | console.error(msg); 101 | }); 102 | }); 103 | 104 | nova.commands.register("python.pipInstall", (workspace) => { 105 | workspace.showInputPalette( 106 | "Install packages into your virtual environment.", 107 | { placeholder: "Package names or specifiers" }, 108 | function (spec) { 109 | if (!spec) return; 110 | let packages = spec.split(" ").filter((e) => e.length > 0); 111 | let note = new Notification( 112 | packages.join(", "), 113 | "Installing Packages" 114 | ).show(); 115 | pip.install(packages).then(() => { 116 | note.dismiss(); 117 | sidebar.refresh(); 118 | }); 119 | } 120 | ); 121 | }); 122 | 123 | nova.commands.register("python.pipUninstall", (workspace) => { 124 | workspace.showInputPalette( 125 | "Un-install packages from your virtual environment.", 126 | { placeholder: "Package names" }, 127 | function (spec) { 128 | let packages = spec.split(" ").filter((e) => e.length > 0); 129 | let note = new Notification( 130 | packages.join(", "), 131 | "Uninstalling Packages" 132 | ).show(); 133 | pip.uninstall(packages).then(() => { 134 | note.dismiss(); 135 | sidebar.refresh(); 136 | }); 137 | } 138 | ); 139 | }); 140 | 141 | nova.commands.register("python.upgradeAllPackages", (workspace) => { 142 | let filename = config.get("pipRequirementsInput", "string", "requirements.in"); 143 | let path = nova.path.join(nova.workspace.path, filename); 144 | if (nova.fs.access(path, nova.fs.R_OK)) { 145 | let note = new Notification("Upgrading all packages").show(); 146 | pip.upgrade(["-r", path]).then(() => { 147 | note.dismiss(); 148 | sidebar.refresh(); 149 | }); 150 | } else { 151 | new Notification(`File not found: ${filename}`).show(); 152 | } 153 | }); 154 | 155 | nova.commands.register("python.upgradeSelectedPackages", (workspace) => { 156 | var packages = sidebar.selectedPackages(); 157 | let note = new Notification(packages.join(", "), "Upgrading Packages").show(); 158 | pip.upgrade(packages).then(() => { 159 | note.dismiss(); 160 | sidebar.refresh(); 161 | }); 162 | }); 163 | 164 | nova.commands.register("python.uninstallSelectedPackages", (workspace) => { 165 | var packages = sidebar.selectedPackages(); 166 | let note = new Notification(packages.join(", "), "Uninstalling Packages").show(); 167 | pip.uninstall(packages).then(() => { 168 | note.dismiss(); 169 | sidebar.refresh(); 170 | }); 171 | }); 172 | 173 | // Pyright 174 | nova.commands.register("python.restartPyright", (workspace) => langserver.start()); 175 | 176 | // Formatting 177 | nova.commands.register("python.format", formatter.format, formatter); 178 | nova.commands.register("python.formatWorkspace", formatter.formatWorkspace, formatter); 179 | 180 | // Linting 181 | nova.commands.register("python.check", linter.manualCheck, linter); 182 | nova.commands.register("python.fix", linter.fix, linter); 183 | nova.commands.register("python.fixWorkspace", linter.fixWorkspace, linter) 184 | nova.commands.register("python.organizeImports", linter.organize, linter); 185 | nova.commands.register("python.organizeWorkspace", linter.organizeWorkspace, linter); 186 | nova.commands.register("python.fixOrganizeWorkspace", linter.fixOrganizeWorkspace, linter) 187 | 188 | // Cleanup 189 | nova.commands.register("python.cleanWorkspace", (workspace) => { 190 | let cmd = nova.path.join(nova.extension.path, "Scripts", "clean.sh"); 191 | let cleanCache = config.get("cleanupCacheFiles", "boolean", true); 192 | let cleanBuild = config.get("cleanupBuildDirs", "boolean", false); 193 | let cleanExtra = config.get("cleanupExtras", "pathArray", []); 194 | return utils 195 | .run( 196 | cmd, 197 | { 198 | env: { 199 | CLEAN_CACHE_FILES: cleanCache ? "1" : "0", 200 | CLEAN_BUILD_ARTIFACTS: cleanBuild ? "1" : "0", 201 | CLEAN_EXTRAS: cleanExtra.join(";"), 202 | }, 203 | }, 204 | workspace.path 205 | ) 206 | .then((result) => { 207 | for (const line of result.stdout) { 208 | console.log(line.trimEnd()); 209 | } 210 | }); 211 | }); 212 | 213 | // Task assistants 214 | nova.assistants.registerTaskAssistant(new PythonTaskAssistant(config), { 215 | identifier: "net.danwatson.Python", 216 | name: "Python", 217 | }); 218 | -------------------------------------------------------------------------------- /Scripts/utils.js: -------------------------------------------------------------------------------- 1 | exports.run = function (cmd, opts = {}, ...args) { 2 | return new Promise((resolve, reject) => { 3 | if (!nova.fs.access(cmd, nova.fs.X_OK)) { 4 | reject(`${cmd} does not exist or is not executable`); 5 | return; 6 | } 7 | 8 | if (typeof opts === "string") { 9 | args.unshift(opts); 10 | opts = {}; 11 | } 12 | 13 | let process = new Process(cmd, { 14 | args: args, 15 | stdio: "pipe", 16 | cwd: opts.cwd || nova.workspace.path, 17 | env: opts.env || {}, 18 | shell: opts.shell || false, 19 | }); 20 | 21 | var stdout = []; 22 | var stderr = []; 23 | var timeoutID = opts.timeout 24 | ? setTimeout(() => { 25 | reject("The process did not respond in a timely manner."); 26 | process.terminate(); 27 | }, opts.timeout) 28 | : null; 29 | 30 | process.onStdout((line) => stdout.push(line)); 31 | process.onStderr((line) => stderr.push(line)); 32 | process.onDidExit((code) => { 33 | if (timeoutID) clearTimeout(timeoutID); 34 | return resolve({ 35 | cmd: cmd, 36 | args: args, 37 | stdout: stdout, 38 | stderr: stderr, 39 | status: code, 40 | success: code == 0, 41 | }); 42 | }); 43 | 44 | if (!opts.quiet) { 45 | console.info("Running", cmd, args.join(" ")); 46 | } 47 | 48 | process.start(); 49 | 50 | if (opts.stdin) { 51 | let writer = process.stdin.getWriter(); 52 | writer.ready.then(() => { 53 | writer.write(opts.stdin); 54 | writer.close(); 55 | }); 56 | } 57 | }); 58 | }; 59 | 60 | const versionRegex = /^Python\s(\d+(\.\d+(\.\d+)?)?)/; 61 | 62 | exports.checkEnv = function (venvDir) { 63 | let userRoot = nova.path.expanduser("~"); 64 | let pythonPath = nova.path.join(venvDir, "bin", "python"); 65 | return exports.run(pythonPath, "--version").then((result) => { 66 | let match = result.stdout.join("").match(versionRegex); 67 | let version = match ? match[1] : null; 68 | let relPath = venvDir.replace(userRoot, "~"); 69 | let displayName = version ? `${relPath} (${version})` : relPath; 70 | return [pythonPath, displayName]; 71 | }); 72 | }; 73 | 74 | exports.activatedEnv = function (base = {}) { 75 | const pythonPath = nova.workspace.config.get("python.pythonPath", "string"); 76 | if (!pythonPath) { 77 | return base; 78 | } 79 | const bin = nova.path.dirname(pythonPath); 80 | return { 81 | ...base, 82 | VIRTUAL_ENV: nova.path.dirname(bin), 83 | PATH: bin + ":" + nova.environment.PATH, 84 | }; 85 | }; 86 | 87 | exports.which = function (cmd) { 88 | return exports 89 | .run( 90 | "/usr/bin/which", 91 | { 92 | env: exports.activatedEnv(), 93 | quiet: true, 94 | }, 95 | cmd 96 | ) 97 | .then((result) => { 98 | if (result.success) { 99 | return result.stdout.join("").trim(); 100 | } else { 101 | return Promise.reject(`${cmd} not found`); 102 | } 103 | }); 104 | }; 105 | 106 | function first(promises) { 107 | return Promise.allSettled(promises).then((results) => { 108 | let first = results.find((result) => result.status == "fulfilled"); 109 | if (first) { 110 | return Promise.resolve(first.value); 111 | } 112 | return Promise.reject(new Error("No results.")); 113 | }); 114 | } 115 | 116 | exports.resolvePath = function (cmds, configPath) { 117 | if (configPath) { 118 | return Promise.resolve(configPath); 119 | } 120 | return first(cmds.map((cmd) => exports.which(cmd))); 121 | }; 122 | -------------------------------------------------------------------------------- /Syntaxes/PipRequirements.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Pip Requirements 5 | structured 6 | txt 7 | 8 | 9 | 10 | requirements.txt,requirements.in,requirements.lock,requirements-dev.lock 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Syntaxes/libtree-sitter-requirements.dylib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nova-python/Python.novaextension/ab36183a5259d60195d8384150e4a067051e38f4/Syntaxes/libtree-sitter-requirements.dylib -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "key": "python.venvSearchWorkspace", 4 | "title": "Look for virtual environments in workspace", 5 | "description": "Whether to search for virtual environments in the workspace root.", 6 | "type": "boolean", 7 | "default": true 8 | }, 9 | { 10 | "key": "python.venvDirs", 11 | "title": "Virtual Environment Search Paths", 12 | "description": "Folders to search for virtual environments in.", 13 | "type": "pathArray", 14 | "allowFiles": false, 15 | "allowFolders": true 16 | }, 17 | { 18 | "type": "section", 19 | "title": "Language Server (Pyright)", 20 | "link": "https://microsoft.github.io/pyright/#/settings", 21 | "children": [ 22 | { 23 | "key": "python.pyrightPath", 24 | "title": "Executable", 25 | "description": "Path to the pyright-langserver executable.", 26 | "type": "path", 27 | "placeholder": "/opt/homebrew/bin/pyright-langserver" 28 | }, 29 | { 30 | "key": "python.analysis.stubPath", 31 | "title": "Stub path", 32 | "description": "Optional. A folder to look for type stubs in.", 33 | "type": "path", 34 | "allowFiles": false, 35 | "allowFolders": true 36 | } 37 | ] 38 | }, 39 | { 40 | "type": "section", 41 | "title": "Formatter", 42 | "link": "https://docs.astral.sh/ruff/formatter/", 43 | "children": [ 44 | { 45 | "key": "python.formatterPath", 46 | "title": "Executable", 47 | "description": "Path to a `ruff` or `black` executable. Leave blank to find automatically.", 48 | "type": "path", 49 | "placeholder": "/opt/homebrew/bin/ruff" 50 | }, 51 | { 52 | "key": "python.formatterArgs", 53 | "title": "Additional Arguments", 54 | "description": "The --quiet option is always set. The --stdin-filename option is set when a filename is available.", 55 | "type": "stringArray", 56 | "default": null 57 | } 58 | ] 59 | }, 60 | { 61 | "type": "section", 62 | "title": "Linter", 63 | "link": "https://docs.astral.sh/ruff/linter/", 64 | "children": [ 65 | { 66 | "key": "python.linterPath", 67 | "title": "Executable", 68 | "description": "Path to the `ruff` executable. Leave blank to find automatically.", 69 | "type": "path", 70 | "placeholder": "/opt/homebrew/bin/ruff" 71 | }, 72 | { 73 | "key": "python.linterArgs", 74 | "title": "Additional Arguments", 75 | "description": "The --output-format and --quiet options are always set.", 76 | "type": "stringArray" 77 | } 78 | ] 79 | }, 80 | { 81 | "type": "section", 82 | "title": "Pip", 83 | "children": [ 84 | { 85 | "key": "python.pipAuditPath", 86 | "title": "pip-audit Executable", 87 | "description": "Path to the `pip-audit` executable. Leave blank to find automatically.", 88 | "type": "path", 89 | "placeholder": "/opt/homebrew/bin/pip-audit" 90 | } 91 | ] 92 | } 93 | ] 94 | -------------------------------------------------------------------------------- /configWorkspace.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "key": "python.pythonPath", 4 | "title": "Python Interpreter", 5 | "type": "enum", 6 | "resolve": "python.resolveEnvs", 7 | "allowCustom": true 8 | }, 9 | { 10 | "title": "Set as built-in interpreter", 11 | "description": "Other extensions (including the Python Debug tasks) may use the built-in python.interpreter setting.", 12 | "type": "command", 13 | "command": "python.copyInterpreter" 14 | }, 15 | { 16 | "type": "section", 17 | "title": "Language Server (Pyright)", 18 | "link": "https://microsoft.github.io/pyright/#/settings", 19 | "children": [ 20 | { 21 | "key": "python.pyrightEnabled", 22 | "title": "Enable language server", 23 | "description": "Whether to enable language server integration for this project.", 24 | "type": "boolean", 25 | "default": true 26 | }, 27 | { 28 | "key": "python.analysis.typeCheckingMode", 29 | "title": "Type checking mode", 30 | "description": "How much type checking you want to do.", 31 | "type": "enum", 32 | "values": [ 33 | ["off", "Off"], 34 | ["basic", "Basic"], 35 | ["strict", "Strict"] 36 | ], 37 | "default": "basic" 38 | }, 39 | { 40 | "key": "python.analysis.diagnosticMode", 41 | "title": "Diagnostic mode", 42 | "description": "Whether to report issues for all files in the workspace, or open files only.", 43 | "type": "enum", 44 | "values": [ 45 | ["openFilesOnly", "Open files only"], 46 | ["workspace", "All files in workspace"] 47 | ], 48 | "default": "openFilesOnly" 49 | }, 50 | { 51 | "key": "pyright.disableTaggedHints", 52 | "title": "Disable tagged hints", 53 | "description": "Disables the use of hint diagnostics with special tags (for example: `x is not accessed`)", 54 | "type": "boolean", 55 | "default": false 56 | }, 57 | { 58 | "key": "python.pyrightPath", 59 | "title": "Executable", 60 | "description": "Leave blank to use the extension default.", 61 | "type": "path", 62 | "placeholder": "(default)" 63 | }, 64 | { 65 | "key": "python.analysis.stubPath", 66 | "title": "Stub path", 67 | "description": "A folder to look for type stubs in. Leave blank to use the extension default.", 68 | "type": "path", 69 | "allowFiles": false, 70 | "allowFolders": true 71 | }, 72 | { 73 | "key": "python.analysis.extraPaths", 74 | "title": "Extra paths", 75 | "description": "Additional paths to include in analysis.", 76 | "type": "pathArray", 77 | "allowFiles": false, 78 | "allowFolders": true 79 | } 80 | ] 81 | }, 82 | { 83 | "type": "section", 84 | "title": "Formatter", 85 | "link": "https://docs.astral.sh/ruff/formatter/", 86 | "children": [ 87 | { 88 | "key": "python.formatterPath", 89 | "title": "Executable", 90 | "description": "Path to a `ruff` or `black` executable. Leave blank to find automatically.", 91 | "type": "path", 92 | "placeholder": "(default)" 93 | }, 94 | { 95 | "key": "python.formatterArgs", 96 | "title": "Additional Arguments", 97 | "description": "The --quiet option is always set. The --stdin-filename option is set when a filename is available.", 98 | "type": "stringArray", 99 | "default": null 100 | }, 101 | { 102 | "key": "python.formatOnSave", 103 | "title": "Format on save", 104 | "type": "boolean", 105 | "default": false 106 | } 107 | ] 108 | }, 109 | { 110 | "type": "section", 111 | "title": "Linter", 112 | "link": "https://docs.astral.sh/ruff/linter/", 113 | "children": [ 114 | { 115 | "key": "python.linterPath", 116 | "title": "Executable", 117 | "description": "Path to the `ruff` executable. Leave blank to find automatically.", 118 | "type": "path", 119 | "placeholder": "(default)" 120 | }, 121 | { 122 | "key": "python.linterArgs", 123 | "title": "Additional Arguments", 124 | "description": "The --output-format and --quiet options are always set.", 125 | "type": "stringArray" 126 | }, 127 | { 128 | "key": "python.linterCheckMode", 129 | "title": "Check Mode", 130 | "description": "An event on which to perform a check.", 131 | "type": "enum", 132 | "values": [ 133 | ["onChange", "On file change"], 134 | ["onSave", "On file save"], 135 | ["-", "Command only"] 136 | ], 137 | "default": "onChange" 138 | }, 139 | { 140 | "key": "python.fixOnSave", 141 | "title": "Fix violations on save", 142 | "type": "boolean", 143 | "default": false 144 | }, 145 | { 146 | "key": "python.organizeOnSave", 147 | "title": "Organize imports on save", 148 | "type": "boolean", 149 | "default": false 150 | } 151 | ] 152 | }, 153 | { 154 | "type": "section", 155 | "title": "Pip", 156 | "children": [ 157 | { 158 | "key": "python.pipRequirements", 159 | "title": "Frozen requirements", 160 | "type": "path", 161 | "relative": true, 162 | "placeholder": "requirements.txt" 163 | }, 164 | { 165 | "key": "python.pipRequirementsInput", 166 | "title": "Input requirements", 167 | "description": "Optional. Top-level requirements from which to update and generate frozen requirements. Do not include dependencies you don't use directly.", 168 | "type": "path", 169 | "relative": true, 170 | "placeholder": "requirements.in" 171 | }, 172 | { 173 | "key": "python.pipExclude", 174 | "title": "Exclude packages on freeze", 175 | "description": "", 176 | "type": "stringArray" 177 | }, 178 | { 179 | "key": "python.pipUpgradeStrategy", 180 | "title": "Upgrade strategy", 181 | "description": "", 182 | "type": "enum", 183 | "values": [ 184 | ["only-if-needed", "Only if needed (default)"], 185 | ["eager", "Eager"] 186 | ], 187 | "default": "only-if-needed" 188 | } 189 | ] 190 | }, 191 | { 192 | "type": "section", 193 | "title": "Cleanup", 194 | "children": [ 195 | { 196 | "key": "python.cleanupCacheFiles", 197 | "title": "Clean cache files and directories", 198 | "description": "Removes all *.pyc files and __pycache__ directories.", 199 | "type": "boolean", 200 | "default": true 201 | }, 202 | { 203 | "key": "python.cleanupBuildDirs", 204 | "title": "Clean build artifacts", 205 | "description": "Removes top-level dist, build, and egg-info directories.", 206 | "type": "boolean", 207 | "default": false 208 | }, 209 | { 210 | "key": "python.cleanupExtras", 211 | "title": "Clean additional files or directories", 212 | "description": "Removes additional files or directories relative to your workspace root.", 213 | "type": "pathArray", 214 | "allowFiles": true, 215 | "allowFolders": true, 216 | "relative": true 217 | } 218 | ] 219 | } 220 | ] 221 | -------------------------------------------------------------------------------- /extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "net.danwatson.Python", 3 | "name": "Python", 4 | "organization": "Dan Watson", 5 | "description": "Batteries-included Python support for Nova.", 6 | "version": "1.4.1", 7 | "categories": [ 8 | "commands", 9 | "languages", 10 | "sidebars", 11 | "formatters", 12 | "issues", 13 | "tasks" 14 | ], 15 | "repository": "https://github.com/nova-python/Python.novaextension", 16 | "bugs": "https://github.com/nova-python/Python.novaextension/issues", 17 | 18 | "main": "main.js", 19 | 20 | "entitlements": { 21 | "process": true, 22 | "filesystem": "readwrite" 23 | }, 24 | 25 | "activationEvents": [ 26 | "onLanguage:python", 27 | "onWorkspaceContains:*.py", 28 | "onWorkspaceContains:requirements.txt", 29 | "onWorkspaceContains:requirements.in", 30 | "onWorkspaceContains:requirements.lock", 31 | "onWorkspaceContains:uv.lock", 32 | "onWorkspaceContains:pyrightconfig.json", 33 | "onWorkspaceContains:pyproject.toml", 34 | "onCommand:python.resolveEnvs" 35 | ], 36 | 37 | "config": "config.json", 38 | 39 | "configWorkspace": "configWorkspace.json", 40 | 41 | "commands": { 42 | "editor": [ 43 | { 44 | "title": "Format", 45 | "paletteTitle": "Format", 46 | "command": "python.format", 47 | "shortcut": "cmd-shift-B", 48 | "filters": { 49 | "syntaxes": ["python"] 50 | } 51 | }, 52 | { 53 | "title": "Check", 54 | "paletteTitle": "Check Violations", 55 | "command": "python.check", 56 | "shortcut": "shift-cmd-8", 57 | "filters": { 58 | "syntaxes": ["python"] 59 | } 60 | }, 61 | { 62 | "title": "Fix", 63 | "paletteTitle": "Fix Violations", 64 | "command": "python.fix", 65 | "shortcut": "shift-cmd-opt-8", 66 | "filters": { 67 | "syntaxes": ["python"] 68 | } 69 | }, 70 | { 71 | "title": "Organize Imports", 72 | "paletteTitle": "Organize Imports", 73 | "command": "python.organizeImports", 74 | "shortcut": "shift-opt-O", 75 | "filters": { 76 | "syntaxes": ["python"] 77 | } 78 | } 79 | ], 80 | "extensions": [ 81 | { 82 | "title": "Restart Pyright", 83 | "command": "python.restartPyright" 84 | }, 85 | { 86 | "title": "Freeze Requirements", 87 | "paletteTitle": "Freeze Requirements (Pip)", 88 | "command": "python.pipFreeze" 89 | }, 90 | { 91 | "title": "Upgrade All Requirements", 92 | "paletteTitle": "Upgrade All Requirements (Pip)", 93 | "command": "python.upgradeAllPackages" 94 | }, 95 | { 96 | "title": "Install Package", 97 | "paletteTitle": "Install Package (Pip)", 98 | "command": "python.pipInstall" 99 | }, 100 | { 101 | "title": "Uninstall Package", 102 | "paletteTitle": "Uninstall Package (Pip)", 103 | "command": "python.pipUninstall" 104 | }, 105 | { 106 | "title": "Format Workspace", 107 | "paletteTitle": "Format (Workspace)", 108 | "command": "python.formatWorkspace", 109 | "shortcut": "cmd-shift-opt-B" 110 | }, 111 | { 112 | "title": "Fix All Workspace Violations", 113 | "paletteTitle": "Fix Violations (Workspace)", 114 | "command": "python.fixWorkspace" 115 | }, 116 | { 117 | "title": "Organize All Workspace Imports", 118 | "paletteTitle": "Organize Imports (Workspace)", 119 | "command": "python.organizeWorkspace" 120 | }, 121 | { 122 | "title": "Fix and Organize All Workspace Imports", 123 | "paletteTitle": "Fix and Organize Imports (Workspace)", 124 | "command": "python.fixOrganizeWorkspace" 125 | }, 126 | { 127 | "title": "Clean Workspace", 128 | "command": "python.cleanWorkspace", 129 | "shortcut": "cmd-shift-opt-K" 130 | } 131 | ] 132 | }, 133 | 134 | "issueMatchers": { 135 | "ruff": { 136 | "pattern": { 137 | "regexp": "^::(\\w+)\\stitle=.+,file=.+,line=(\\d+),col=(\\d+),endLine=(\\d+),endColumn=(\\d+)::.+:\\d+:\\s(\\w+)\\s(.*)", 138 | "message": 7, 139 | "code": 6, 140 | "line": 2, 141 | "column": 3, 142 | "endLine": 4, 143 | "endColumn": 5, 144 | "severity": 1 145 | } 146 | } 147 | }, 148 | 149 | "sidebars": [ 150 | { 151 | "id": "python.packages", 152 | "name": "Packages", 153 | "smallImage": "sidebar-small", 154 | "largeImage": "sidebar-large", 155 | "sections": [ 156 | { 157 | "id": "python.packages.installed", 158 | "name": "Installed Packages", 159 | "placeholderText": "Reload to show package information.", 160 | "allowMultiple": true, 161 | "headerCommands": [ 162 | { 163 | "title": "Install", 164 | "image": "__builtin.add", 165 | "command": "python.pipInstall", 166 | "tooltip": "Install package(s)" 167 | }, 168 | { 169 | "title": "Refresh", 170 | "image": "reload", 171 | "command": "python.reloadPackages", 172 | "tooltip": "Refresh the list of installed packages" 173 | }, 174 | { 175 | "title": "Freeze", 176 | "image": "freeze", 177 | "command": "python.pipFreeze", 178 | "tooltip": "Freeze requirements" 179 | }, 180 | { 181 | "title": "Upgrade All", 182 | "image": "update", 183 | "command": "python.upgradeAllPackages", 184 | "tooltip": "Upgrade all requirements" 185 | } 186 | ], 187 | "contextCommands": [ 188 | { 189 | "title": "Upgrade to latest version", 190 | "command": "python.upgradeSelectedPackages", 191 | "when": "viewItem != null" 192 | }, 193 | { 194 | "title": "Uninstall", 195 | "command": "python.uninstallSelectedPackages", 196 | "when": "viewItem != null" 197 | } 198 | ] 199 | } 200 | ] 201 | } 202 | ], 203 | 204 | "taskTemplates": { 205 | "cleanup": { 206 | "name": "Python Cleanup", 207 | "description": "Clean up cache files and build artifacts.", 208 | "image": "python-nova", 209 | "tasks": { 210 | "clean": { 211 | "resolve": "net.danwatson.Python", 212 | "data": { "name": "cleanup" } 213 | } 214 | }, 215 | "config": [ 216 | { 217 | "key": "python.cleanupCacheFiles", 218 | "title": "Clean cache files and directories", 219 | "description": "Removes all *.pyc files and __pycache__ directories.", 220 | "type": "boolean", 221 | "default": true 222 | }, 223 | { 224 | "key": "python.cleanupBuildDirs", 225 | "title": "Clean build artifacts", 226 | "description": "Removes top-level dist, build, and egg-info directories.", 227 | "type": "boolean", 228 | "default": false 229 | }, 230 | { 231 | "key": "python.cleanupExtras", 232 | "title": "Clean additional files or directories", 233 | "description": "Removes additional files or directories relative to your workspace root.", 234 | "type": "pathArray", 235 | "allowFiles": true, 236 | "allowFolders": true, 237 | "relative": true 238 | } 239 | ] 240 | }, 241 | "virtualenv": { 242 | "name": "Virtual Environment", 243 | "description": "Run scripts or Python modules in an activated virtual environment.", 244 | "image": "python-nova", 245 | "tasks": { 246 | "run": { 247 | "resolve": "net.danwatson.Python", 248 | "data": { "name": "run" } 249 | } 250 | }, 251 | "config": [ 252 | { 253 | "key": "script", 254 | "title": "Script", 255 | "type": "path", 256 | "relative": true, 257 | "description": "The path to a script to run with the virtual environment activated. This option is mutually exclusive with \"Module\". If both are specified, the script will be preferred." 258 | }, 259 | { 260 | "key": "module", 261 | "title": "Module", 262 | "type": "string", 263 | "description": "The name of a Python module to executing using the virtual environment's Python interpreter. This option is mutually exclusive with \"Script\". If both are specified, the script will be preferred." 264 | }, 265 | { 266 | "key": "args", 267 | "title": "Arguments", 268 | "description": "Custom arguments to pass to the target.", 269 | "type": "stringArray" 270 | }, 271 | { 272 | "key": "workdir", 273 | "title": "Working Directory", 274 | "description": "The working directory in which to invoke the script. By default, the project folder is used.", 275 | "type": "path", 276 | "placeholder": "Project Root", 277 | "allowFiles": false, 278 | "allowFolders": true, 279 | "relative": true 280 | } 281 | ] 282 | } 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nova-python/Python.novaextension/ab36183a5259d60195d8384150e4a067051e38f4/extension.png -------------------------------------------------------------------------------- /extension@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nova-python/Python.novaextension/ab36183a5259d60195d8384150e4a067051e38f4/extension@2x.png -------------------------------------------------------------------------------- /python-sidebar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nova-python/Python.novaextension/ab36183a5259d60195d8384150e4a067051e38f4/python-sidebar.png --------------------------------------------------------------------------------