Wing Tips: Extending Wing with Python (Part 4 of 4)

Jul 10, 2019


In this issue of Wing Tips we continue to look at how to extend Wing's functionality, by taking a closer look at at the scripting API and writing up a more complex script.

If you haven't read the previous installments of this series, you may want to take a look at Part 1 where we introduced Wing's scripting framework and set up auto-completion for the scripting API, Part 2 where we used Wing to debug itself for easier extension script development, and Part 3 where we looked at how to collect arguments from the user.

Overview of the Scripting API

Wing's formal scripting API is found in the file wingapi.py, which is located inside src in the Install Directory listed in Wing's About box. This is the API that we worked with in the previous three installments of this series. It lets you control the application and its configuration, run and inspect code in the debugger, open and alter files in an editor, create and manage projects, search files and directories, and access Wing's static analysis of Python code.

The API is divided into a number of classes for accessing each of these areas of functionality:

  • CAPIApplication is a singleton found in wingapi.gApplication. This is the the main point of access to the application, with support for creating and accessing windows, editors, projects, tools, the debugger, child processes, preferences, source code analysis, and other parts of Wing's functionality.

    Some of this functionality is exposed through the other API classes listed below. Other parts of Wing's functionality is instead accessed through the method CAPIApplication.ExecuteCommand(), which can invoke all of the documented commands which are not exposed directly through the Python API. Keyword arguments can be passed to commands that take them, for example ExecuteCommand('replace-string', search_string="tset", replace_string="test")

    CAPIApplication also provides access to all of Wing's documented preferences with GetPreference() and SetPreference().

  • CAPIDebugger and CAPIDebugRunState allow controlling the debugger and debug processes.
  • CAPIDocument and CAPIEditor implement document and editor functionality. Each CAPIDocument may be displayed in one or more CAPIEditor instances in different splits in the user interface.
  • CAPIProject lets you create projects, access project-level functionality, and alter project configuration.
  • CAPISearch provides access to Wing's search capabilities, to search files or directories.
  • CAPIStaticAnalysis is available for each CAPIDocument that contains Python, to access Wing's static analysis of that file. Information on each symbol found in code is encapsulated in CAPISymbolInfo.

Scripts also have access to the entire Python standard library in the version of Python that Wing uses internally to run itself (currently 2.7)

The API Reference details the entire API and documentation is displayed in the Source Assistant in Wing as you work with wingapi, provided that you configured your project for scripting as described in Part 1 of this series.

A More Advanced Scripting Example

As a final example, let's look at a simplified test code generator, that creates a unittest testing skeleton for a selected class, opens it side by side with the code being tested, and scrolls to the method being tested as you move through the test skeleton. Here is the complete solution, with comments explaining each part:

import os
import wingapi

g_last_target = [None]

def generate_tests_for_class():
  """Generate a test skeleton for the class at the current editor focus, open
  it side by side with the original source, and set up automatic navigation
  of the source being tested."""

  # Collect objects we need to work with:  The app and the editor we're starting from
  app = wingapi.gApplication
  code_editor = app.GetActiveEditor()

  # Try to find a class at current editor focus
  fn, scope = _find_class(code_editor)
  if scope is None:
    app.ShowMessageDialog("No Class Found", "Could not generate tests:  "
                          "No class was found at current focus")
    return

  # Get the methods for the class
  code_ana = app.GetAnalysis(fn)
  contents = code_ana.GetScopeContents('.'.join(scope), timeout=3.0)
  methods = [s for s in contents if 'method' in contents[s]]

  # Create the test skeleton, with one test for each public method
  output = [
    'import unittest',
    '',
    'class Test{}(unittest.TestCase):'.format(scope[-1]),
  ]
  for method in methods:
    if method.startswith('_'):
      continue
    output.extend([
      '',
      _indent(code_editor, 1) + 'def test{}(self):'.format(method),
      _indent(code_editor, 2) + 'pass',
    ])
  if len(output) == 3:
    output.extend([
      '',
      _indent(code_editor, 1) +'pass',
    ])

  # Set up a side-by-side mode after saving the current visual state
  visual_state = app.GetVisualState(style='tools-and-editors')
  app.ExecuteCommand('unsplit')
  app.ExecuteCommand('split-horizontally')

  # Open up the test skeleton in an unsaved file with name based on the original code
  test_fn = os.path.join(os.path.dirname(fn), 'test_{}'.format(os.path.basename(fn)))
  test_editor = app.OpenEditor(test_fn)
  test_editor.GetDocument().SetText(code_editor.GetEol().join(output))

  # Connect to the test editor so we can show the matching method in the source
  # when the caret moves around within the tests
  def track_position(start, end):
    lineno = test_editor.GetDocument().GetLineNumberFromPosition(start) + 1
    test_ana = app.GetAnalysis(test_editor.GetDocument().GetFilename())
    scope = test_ana.FindScopeContainingLine(lineno)
    scope = scope.split('.')
    if scope:
      if not scope[0].startswith('Test') or not scope[-1].startswith('test'):
        return
      scope[0] = scope[0][len('Test'):]
      scope[-1] = scope[-1][len('test'):]
      scope = _find_symbol(code_ana, scope, 'method')
    if not scope:
      return
    if g_last_target[0] == scope:
      return
    g_last_target[0] = scope
    infos = code_ana.GetSymbolInfo('.'.join(scope[:-1]), scope[-1])
    if not infos:
      return
    lineno = infos[0].lineStart
    start = code_editor.GetDocument().GetLineStart(lineno) + infos[0].pos
    end = start + len(scope[-1])
    code_editor.ScrollToLine(max(0, lineno-2), select=(start, end), pos='top', callout=1)
  test_editor.Connect('selection-changed', track_position)

  # Connect to the test editor so we can restore the prior visual state when it is closed
  def restore_state(unused):
    app.SetVisualState(visual_state)
  test_editor.Connect('destroy', restore_state)

  app.ShowMessageDialog("Tests Generated", "Tests for class {} have been generated.  ".format(scope[-1]) +
                        "As you fill in the tests, the method being tested is shown on the left as you "
                        "move around the test file on the right.  Closing the tests will return Wing to "
                        "the previous visual state.")

# Command configuration

def _generate_tests_for_class_available():
  ed = wingapi.gApplication.GetActiveEditor()
  if ed is None:
    return False
  mime = wingapi.gApplication.GetMimeType(ed.GetDocument().GetFilename())
  if mime != 'text/x-python':
    return False
  return _find_class(ed)[1] is not None

generate_tests_for_class.available = _generate_tests_for_class_available
generate_tests_for_class.label = "Generate Tests for Class"
generate_tests_for_class.contexts = [wingapi.kContextNewMenu('Scripts', 1)]

# Utilities

def _indent(ed, level):
  """Get indentation that matches the file we're writing the test for"""
  indent_style = ed.GetIndentStyle()
  if indent_style == 1:
    return ' ' * ed.GetIndentSize() * level
  elif indent_style == 2:
    return '\t' * level
  else:
    tabs = ed.GetIndentSize() * level / ed.GetTabSize()
    spaces = ed.GetIndentSize() * level - (tabs * ed.GetTabSize)
    return '\t' * tabs + ' ' * spaces

def _find_symbol(ana, scope, symbol_type):
  """Find the outermost symbol of given type in the given scope"""
  for i in range(0, len(scope)):
    infos = ana.GetSymbolInfo('.'.join(scope[:i]), scope[i])
    for info in infos:
      if symbol_type in info.generalType:
        return scope[:i+1]
  return None

def _find_class(ed):
  """Find the current top-level class in the given code editor"""
  if ed is None:
    return None, None
  scope = ed.GetSourceScope()
  if scope is None:
    return None, None
  fn, lineno = scope[:2]
  ana = wingapi.gApplication.GetAnalysis(fn)
  scope = _find_symbol(ana, scope[2:], 'class')
  return fn, scope

Key Concepts

The important things to observe in this example are:

(1) Wing's source code analyzer is used to find the current class, in the utilities _find_class and _find_symbol: GetSourceScope() is called to get the scope list for the current position in the active editor, and this is inspected with GetSymbolInfo() to find the outermost class in the path to the current scope. For example, if you are within a nested function nested_function in a method MyMethod of class MyClass, your scope is ["MyClass", "MyMethod", "nested_function"] and the script needs to determine that MyClass is the outermost class.

(2) The attributes in the class are found with GetScopeContents() and then narrowed to contain only methods using the filter utility _find_symbol that was also used to find the class.

(3) The script uses ExecuteCommand to invoke other Wing commands (unsplit and split-horizontally) in order to set up the side-by-side display mode. This is how you can access all of the documented commands. Since calling these commands changes the layout of the user inteface, the original layout is saved with GetVisualState and restored later.

(4) Signal connections made with Connect are used on the editor to hook functionality to specific events in the user interface. In this case, the selection-changed signal on the editor that contains the generated test skeleton is used to scroll the original source to the method that is being tested. The script also connects to destroy on the same editor, in order to terminate the session and restore the original visual layout when the generated test skeleton is closed. Most of the classes in the API provide signals like this. Although there are none in this example, regularly occurring tasks may also be scheduled, using wingapi.gApplication.InstallTimeout().

Try it Out

To try the example, copy the following into a new file in the scripts directory in Wing's settings directory (listed in Wing's About box) and use Reload All Scripts so Wing discovers and starts watching and reloading the new script file when it changes. A Scripts menu with item Generate Tests for Class should appear in the menu bar. If the menu item does not appear, try restarting Wing (this may be needed on Windows in some versions of Wing).

In order for this to work, you will need open a Python file with a class that has public methods (with names not starting with _ underscore) and move the caret into the class. If you don't have one, open src/wingapi.py from your Wing installation and use one of the classes in that file.

After you invoke the command, it splits the display, places the original code on the left and the test skeleton on the right, and displays a message dialog:

/images/blog/scripting-4/side-by-side.png

If you close the dialog and move the caret around the test skeleton, track_position() in the script will be called each time the editor selection changes in order to track your movement in the original source code like this:

/images/blog/scripting-4/tracking.gif

Closing the test skeleton (with or without saving it) will invoke restore_state to return Wing's display to how it was before you invoked the script to generate the skeleton.

Further Reading

A real world version of this example would need to merge new test classes and methods into an existing file, allow entering test mode without generating a new skeleton, keep tests in file or alphabetical order, add setUp and tearDown methods or other boilerplate, match the active test framework for the current project, and make other refinements. All of these are feasible using Wing's scripting API.

For more information on writing extension scripts, see Scripting and Extending Wing in the documentation.

Other example scripts can be found in the scripts directory in your Wing installation, and extensions may be contributed to other users at wing-contrib.



That's it for now! We'll be back in the next Wing Tip with more helpful hints for Wing Python IDE.



Share this article: