From 95e360b170d5cae4f18959b0c17e2fb2f9927a12 Mon Sep 17 00:00:00 2001 From: Rayduck Date: Sat, 1 Jun 2024 17:46:36 +0100 Subject: [PATCH] python: Add runnable unittest tasks (#12451) Add runnable tasks for Python, starting with `unittest` from the standard library. Both `TestCase`s (classes meant to be a unit of testing) and individual test functions in a `TestCase` will have runnable icons. For completeness, I also included a task that will run `unittest` on the current file. The implementation follows the `unittest` CLI. The unittest module can be used from the command line to run tests from modules, classes or even individual test methods: ``` python -m unittest test_module.TestClass python -m unittest test_module.TestClass.test_method ``` ```python import unittest class TestStringMethods(unittest.TestCase): def test_upper(self): self.assertEqual('foo'.upper(), 'FOO') def test_isupper(self): self.assertTrue('FOO'.isupper()) self.assertFalse('Foo'.isupper()) def test_split(self): s = 'hello world' self.assertEqual(s.split(), ['hello', 'world']) # check that s.split fails when the separator is not a string with self.assertRaises(TypeError): s.split(2) if __name__ == '__main__': unittest.main() ``` From the snippet provided by `unittest` docs, a user may want to run test_split independently of the other test functions in the test case. Hence, I decided to make each test function runnable despite `TestCase`s being the unit of testing. ## Example of running a `TestCase` image ## Example of running a test function in a `TestCase` image `unittest` will also run the `setUp` and `tearDown` fixtures. Eventually, I want to add the more commonly used `pytest` runnables (perhaps as an extension instead). Release Notes: - Added runnable tasks for Python `unittest`. ([#12080](https://github.com/zed-industries/zed/issues/12080)). --- crates/languages/src/lib.rs | 8 +- crates/languages/src/python.rs | 105 ++++++++++++++++++---- crates/languages/src/python/runnables.scm | 31 +++++++ 3 files changed, 122 insertions(+), 22 deletions(-) create mode 100644 crates/languages/src/python/runnables.scm diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 6267005611fdb59658e2eae337d4fe3d4875935d..b9ee0f180486bcdf3c73f25886dfca3f359fb115 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -3,6 +3,7 @@ use gpui::{AppContext, UpdateGlobal}; use json::json_task_context; pub use language::*; use node_runtime::NodeRuntime; +use python::PythonContextProvider; use rust_embed::RustEmbed; use settings::SettingsStore; use smol::stream::StreamExt; @@ -10,10 +11,7 @@ use std::{str, sync::Arc}; use typescript::typescript_task_context; use util::{asset_str, ResultExt}; -use crate::{ - bash::bash_task_context, go::GoContextProvider, python::python_task_context, - rust::RustContextProvider, -}; +use crate::{bash::bash_task_context, go::GoContextProvider, rust::RustContextProvider}; mod bash; mod c; @@ -130,7 +128,7 @@ pub fn init( vec![Arc::new(python::PythonLspAdapter::new( node_runtime.clone(), ))], - python_task_context() + PythonContextProvider ); language!( "rust", diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 86a83ef6f9cbfb47084ffa507942e568c956bfdb..a2ec1eef0e8e861898b4d7e02af8faf3e233e62f 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -1,11 +1,11 @@ use anyhow::Result; use async_trait::async_trait; -use language::{LanguageServerName, LspAdapter, LspAdapterDelegate}; +use language::{ContextProvider, LanguageServerName, LspAdapter, LspAdapterDelegate}; use lsp::LanguageServerBinary; use node_runtime::NodeRuntime; -use project::ContextProviderWithTasks; use std::{ any::Any, + borrow::Cow, ffi::OsString, path::{Path, PathBuf}, sync::Arc, @@ -182,21 +182,92 @@ async fn get_cached_server_binary( } } -pub(super) fn python_task_context() -> ContextProviderWithTasks { - ContextProviderWithTasks::new(TaskTemplates(vec![ - TaskTemplate { - label: "execute selection".to_owned(), - command: "python3".to_owned(), - args: vec!["-c".to_owned(), VariableName::SelectedText.template_value()], - ..TaskTemplate::default() - }, - TaskTemplate { - label: format!("run '{}'", VariableName::File.template_value()), - command: "python3".to_owned(), - args: vec![VariableName::File.template_value()], - ..TaskTemplate::default() - }, - ])) +pub(crate) struct PythonContextProvider; + +const PYTHON_UNITTEST_TARGET_TASK_VARIABLE: VariableName = + VariableName::Custom(Cow::Borrowed("PYTHON_UNITTEST_TARGET")); + +impl ContextProvider for PythonContextProvider { + fn build_context( + &self, + variables: &task::TaskVariables, + _location: &project::Location, + _cx: &mut gpui::AppContext, + ) -> Result { + let python_module_name = python_module_name_from_relative_path( + variables.get(&VariableName::RelativeFile).unwrap_or(""), + ); + let unittest_class_name = + variables.get(&VariableName::Custom(Cow::Borrowed("_unittest_class_name"))); + let unittest_method_name = variables.get(&VariableName::Custom(Cow::Borrowed( + "_unittest_method_name", + ))); + + let unittest_target_str = match (unittest_class_name, unittest_method_name) { + (Some(class_name), Some(method_name)) => { + format!("{}.{}.{}", python_module_name, class_name, method_name) + } + (Some(class_name), None) => format!("{}.{}", python_module_name, class_name), + (None, None) => python_module_name, + (None, Some(_)) => return Ok(task::TaskVariables::default()), // should never happen, a TestCase class is the unit of testing + }; + + let unittest_target = ( + PYTHON_UNITTEST_TARGET_TASK_VARIABLE.clone(), + unittest_target_str, + ); + + Ok(task::TaskVariables::from_iter([unittest_target])) + } + + fn associated_tasks(&self) -> Option { + Some(TaskTemplates(vec![ + TaskTemplate { + label: "execute selection".to_owned(), + command: "python3".to_owned(), + args: vec!["-c".to_owned(), VariableName::SelectedText.template_value()], + ..TaskTemplate::default() + }, + TaskTemplate { + label: format!("run '{}'", VariableName::File.template_value()), + command: "python3".to_owned(), + args: vec![VariableName::File.template_value()], + ..TaskTemplate::default() + }, + TaskTemplate { + label: format!("unittest '{}'", VariableName::File.template_value()), + command: "python3".to_owned(), + args: vec![ + "-m".to_owned(), + "unittest".to_owned(), + VariableName::File.template_value(), + ], + ..TaskTemplate::default() + }, + TaskTemplate { + label: "unittest $ZED_CUSTOM_PYTHON_UNITTEST_TARGET".to_owned(), + command: "python3".to_owned(), + args: vec![ + "-m".to_owned(), + "unittest".to_owned(), + "$ZED_CUSTOM_PYTHON_UNITTEST_TARGET".to_owned(), + ], + tags: vec![ + "python-unittest-class".to_owned(), + "python-unittest-method".to_owned(), + ], + ..TaskTemplate::default() + }, + ])) + } +} + +fn python_module_name_from_relative_path(relative_path: &str) -> String { + let path_with_dots = relative_path.replace('/', "."); + path_with_dots + .strip_suffix(".py") + .unwrap_or(&path_with_dots) + .to_string() } #[cfg(test)] diff --git a/crates/languages/src/python/runnables.scm b/crates/languages/src/python/runnables.scm new file mode 100644 index 0000000000000000000000000000000000000000..60c0b8acd7729d8e8309938056d86e70086f9cd7 --- /dev/null +++ b/crates/languages/src/python/runnables.scm @@ -0,0 +1,31 @@ +; subclasses of unittest.TestCase or TestCase +( + (class_definition + name: (identifier) @run @_unittest_class_name + superclasses: (argument_list + [(identifier) @_superclass + (attribute (identifier) @_superclass)] + ) + (#eq? @_superclass "TestCase") + ) @python-unittest-class + (#set! tag python-unittest-class) +) + +; test methods whose names start with `test` in a TestCase +( + (class_definition + name: (identifier) @_unittest_class_name + superclasses: (argument_list + [(identifier) @_superclass + (attribute (identifier) @_superclass)] + ) + (#eq? @_superclass "TestCase") + body: (block + (function_definition + name: (identifier) @run @_unittest_method_name + (#match? @_unittest_method_name "^test.*") + ) @python-unittest-method + (#set! tag python-unittest-method) + ) + ) +)