This post will walk you through an exercise in diving into someone else's code. The goal will be to make an arbitrary change to the code of the Spyder Python IDE, a project I have never touched before in my life, and learn just enough about it to accomplish what I want without getting bogged down. You will learn how to approach problems without the rigour taught in formal education, and instead with guesswork, experimentation, and insight learned in a professional environment. You will see first-hand the joys, sorrows and frustrations trying to navigate the project, culminating in a working (if rough) patch adding a feature to a large, unfamiliar codebase.
Everyone who learns programming has written a pile of code in a bunch of different programs: whether it's implementing algorithms for problem sets, building websites, or making video games. Being a professional software engineer, on the other hand, very often does not involve "writing lots of code"! More often, you spend your time spelunking deep into other peoples code: code you do not understand, did not write, and have possibly never seen before in your life. You have no-one to ask for help, no-one to hear you scream, and yet you have to make forward progress.
About the Author: Haoyi is a software engineer, and the author of many open-source Scala tools such as the Ammonite REPL and the Mill Build Tool. If you enjoyed the contents on this blog, you may also enjoy Haoyi's book Hands-on Scala Programming
The goal of this post is to dive into a codebase we've never seen before and make some non-trivial changes to it. The target of this exercise is going to be the Spyder Python IDE. According to the Github page, it looks something like:
Our (arbitrary) goal is going to add a new dropdown menu next to the Help
dropdown called Misc
, and inside there is going to be a menu-item/command that takes the currently opened text area and prepends the code:
print("Running %s" % __name__)
At the top to indicate when a file starts executing.
I have worked with Python and Javascript professionally for several years now, and am deeply familiar with the ecosystems around both. I'm also experienced working in Linux environments, with the Bash command-line, and have done a lot of open-source Scala as well as some Java/C#/PHP in the ancient past. Nevertheless, I have never used the Spyder editor or touched its codebase in any way, nor have I ever really seen it before today. This exercise will explore how you attack such a codebase from a position of total ignorance and make progress despite your continued partial ignorance.
Here's the order of business approaching an unknown codebase like this:
When working with Other People's Code, there are several things to keep in mind:
You have to know what the project does. In this case it seems to be some sort of graphical IDE for scientific Python, which is great: I know what an IDE is, and what Python is, even though I've never used scientific Python. If it's another project with more specialized usage, make sure you know what it's meant to do before diving in.
The code probably works. If the project is abandoned, or hasn't seen activity in a while, it might not, but Spyder had its last commit two days ago so you can be reasonably sure someone is working on it and it's working enough for them to make changes
There has to be some way of setting it up for "development": making code changes and seeing them work, or not. This is probably distinct from the "bundle everything up for distribution" mode.
It has to be possible* to set it up. If the project is old, abandoned or built by a single person this might not be true, as it might require special preparation that only existed on the original developer's laptop. This one on the other hand seems actively developed by multiple people, so even if setup proves to be a pain in the neck, we at least know it's possible.
It's ok if it takes time to get set up. "Several days" to get set up isn't uncommon in professional settings which have sprawling messes of code, even if it is a bit long. "A few hours" is probably reasonable: my first installation of PHP + MySQL took 9 hours. Ideally you'd be able to get set up with a development environent in seconds, like you can with the IPython project, but not every project lucky enough to be so self-contained and dependency-free.
If you have no idea what a project is or does, you have no hope of trying to make changes to it. In this case, we are looking at the Spyder Python IDE:
This is a Python IDE that is written for Python, in Python, as can be seen from the breakdown on the github page:
At first glance, it has a code editor on the left, a IPython REPL in the bottom right (you can tell rom the tell-tale In [1]
syntax) which supports graphical output similar to IPython's Notebooks, and a documentation browser in the top-right. Otherwise it looks like a pretty typical IDE: buttons at the top to save/load/copy/run/debug/step-through your code, with drop-down menus holding lots more functionality, a tabbed text editor with syntax highlighting and other things.
Spyder seems to run on basically any platform: the docs include instructions for basically all version of Windows, Mac OS-X, and Linux (Ubuntu, Debian, Arch, Fedora, ...)
It seems like a reasonably old (several years), reasonably active project with a good number of contributors:
So it's not just a one-man fly-by-night show. It's not entirely clear whether there's a commercial entity backing it or not.
That's a quick overview of what Spyder appears to be to an un-initiated outsider. Let's try to get Spyder working on our Macbook!
The first thing you want to do when you decide you want to try out a project is figure out how you can install it in "development mode": usually projects will have a nice, all-included, encapsulated distribution that is convenient to download for end-users working within that project, but not the same as what a person working on that project would want to use. In the case of Spyder, this appears to be the distinction between the two sections:
If I was an end-user who just wanted an IDE, I would use the Installation instructions. But since I'm interested in modifying Spyder rather than just using it, I will start with Running from Source. This is always harder, less convenient and more time-consuming than using the "packaged" distribution. Nonetheless, if you are at all interested in making changes to the code, you will need to get over this hump eventually so might as well get over it now.
As is common, the first thing you need to do is download dependencies. Spyder is not developed in a vacuum, and under the section on Dependencies we see:
- Python 2.7 or 3.3+
- PyQt5 5.2+ or PyQt4 4.6+: PyQt5 is recommended.
- qtconsole: Enhanced Python interpreter.
- Rope and Jedi: Editor code completion, calltips and go-to-definition.
- Pyflakes: Real-time code analysis.
- Sphinx: Rich text mode for the Help pane.
- Pygments: Syntax highlighting for all file types it supports.
- Pylint: Static code analysis.
- Pep8: Style analysis.
- Psutil: CPU and memory usage on the status bar.
- Nbconvert: Manipulation of notebooks in the Editor.
- Qtawesome: To have an icon theme based on FontAwesome.
- Pickleshare: Show import completions on the Python consoles.
- PyZMQ: Run introspection services asynchronously.
That's quite a long list! I know what Python is, but I have no idea what the rest of these things seem to be. In principle, I could go and install each of these dependencies one by one using pip, Python's package manager, but I also notice at the top of the Dependencies section is a little disclaimer:
It seems there's a single bundle I can download to install it all! I've never used any of these "Scientific Python Distributions" before, but I've heard of them: they're a pre-packaged Python download that comes complete with a bunch of useful libraries included. In this case, Spyder is telling us that any of these pre-packaged downloads should have everything we need. Thus, the next step in our quest is to go and download one of them to try out, starting with Anaconda
I'm on a Macbook Pro running OS-X, and Googling "Anaconda OS-X" takes me right to the download page:
The installation page seems to start off with Get Superpowers with Anaconda which seems totally meaningless to me, so let's ignore that. There's also an OS-X link, so let's follow that, which brings us to the installer download:
For now, I'm going to go with the command-line download for Python 3.5 instead of Python 2.7. Below the download link, there are instructions to get started:
Command Line Installer:
- Download the command line installer.
- In your terminal window, type one of the below and follow the instructions: Python 2.7:
bash Anaconda2-2.5.0-MacOSX-x86_64.sh
Python 3.5:bash Anaconda3-2.5.0-MacOSX-x86_64.sh
NOTE: Include the "bash" command even if you are not using the bash shell.- Optional: Verify data integrity with MD5.
Anaconda is a big install (>200mb!) and takes 3-4 minutes to download. Running the installer in the command-line as instructed starts off a several-minute-long download-and-install process:
$ bash ../Downloads/Anaconda3-2.5.0-MacOSX-x86_64.sh
Welcome to Anaconda3 2.5.0 (by Continuum Analytics, Inc.)
In order to continue the installation process, please review the license
agreement.
Please, press ENTER to continue
>>>
================
Anaconda License
================
...
Which, after entering yes
or ENTER
to a bunch of questions it asks you, seems to end with:
...
Prepending PATH=/Users/haoyi/anaconda3/bin to PATH in
newly created /Users/haoyi/.bash_profile
For this change to become active, you have to open a new terminal.
Thank you for installing Anaconda3!
Share your notebooks and packages on Anaconda Cloud!
Sign up for free: https://anaconda.org
It turns out that immediately running anaconda
doesn't seem to work, and the python3
command is unchanged:
haoyi-mbp:test haoyi$ anaconda
-bash: anaconda: command not found
haoyi-mbp:test haoyi$ python3
Python 3.4.2 (default, Feb 10 2015, 03:38:22)
[GCC 4.2.1 Compatible Apple LLVM 6.0 (clang-600.0.56)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
Which is probably expected, since the install printouts tell us it only works when you open a new terminal. So opening a new terminal and running anaconda
, we get:
haoyi-mbp:~ haoyi$ anaconda
usage: anaconda [-h] [--show-traceback] [--hide-traceback] [-v] [-q] [--color]
[--no-color] [-V] [-t TOKEN] [-s SITE]
...
anaconda: error: A sub command must be given. To show all available sub commands, run:
anaconda -h
No clue what that is, but I guess we installed something! Running python3
now, we get
$ python3
Python 3.5.1 |Anaconda 2.5.0 (x86_64)| (default, Dec 7 2015, 11:24:55)
[GCC 4.2.1 (Apple Inc. build 5577)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
With the Anaconda
in the introductory banner, it seems we've installed Anaconda successfully! As for the last step "Verify data integrity with MD5", let's just YOLO it for now.
Now that Anaconda is sorted out, let's go back to the Running from Source part of the documentation. Directly underneath the header, before all that talk of dependencies, we see
The fastest way to run Spyder is to get the source code using git, install PyQt5 or PyQt4, and run these commands:
- Install our runtime dependencies (see below).
cd /your/spyder/git-clone
python bootstrap.py
You may want to do this for fixing bugs in Spyder, adding new features, learning how Spyder works or just getting a taste of it.
I guess it expects us to have cloned the Spyder repository in order to cd
into it. Let's do that using the URL listed on the Github page:
$ git clone git@github.com:spyder-ide/spyder.git
Cloning into 'spyder'...
remote: Counting objects: 43311, done.
remote: Compressing objects: 100% (393/393), done.
remote: Total 43311 (delta 263), reused 0 (delta 0), pack-reused 42918
Receiving objects: 100% (43311/43311), 28.27 MiB | 3.53 MiB/s, done.
Resolving deltas: 100% (33096/33096), done.
Checking connectivity... done.
If anyone wants to follow along, the commit at-time-of-writing is
And then cd
ing into it and running it:
$ python3 bootstrap.py
Executing Spyder from source checkout
Revision df9577c, Branch: master
01. Patched sys.path with /Users/haoyi/test/spyder
02. No PyQt5 or PyQt4 detected, using PySide if available (deprecated)
Traceback (most recent call last):
File "/Users/haoyi/test/spyder/spyderlib/qt/__init__.py", line 62, in <module>
from PySide import __version__ # analysis:ignore
ImportError: No module named 'PySide'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "bootstrap.py", line 140, in <module>
versions = get_versions(reporev=False)
File "/Users/haoyi/test/spyder/spyderlib/__init__.py", line 65, in get_versions
import spyderlib.qt
File "/Users/haoyi/test/spyder/spyderlib/qt/__init__.py", line 65, in <module>
raise ImportError("Spyder requires PyQt5, PyQt4 or PySide (deprecated) "
ImportError: Spyder requires PyQt5, PyQt4 or PySide (deprecated) to be installed
$
I guess when the docs said "Most if not all the dependencies listed below come with Anaconda..." it was definitely a most and not an all. No matter, let's install PyQt5 (listed on the github readme as "recommended") using pip3
, the Python 3 version of pip
that should correspond with the python3
executable that came with Anaconda:
$ pip3 install PyQt5
You are using pip version 6.0.8, however version 8.1.0 is available.
You should consider upgrading via the 'pip install --upgrade pip' command.
Collecting PyQt5
Could not find any downloads that satisfy the requirement PyQt5
No distributions at all found for PyQt5
$
Nope? Googling for "pip install PyQt5" gives:
Which makes it appear like you can't install this library from PyPI using pip. Googling "osx pyqt5" gives:
It appears there isn't any way to install PyQt5 easily without having to do a whole bunch of manual tar
ing and make
ing at the command line. On the other hand, the error message earlier said that the PySide module was deprecated, but it might still work! For now, I don't care about deprecation warnings, I just want to get something (*anything*) that works. Googling "pip install pyside" finds a page for that module on the standard module repository:
Trying to install that using pip3
seems to work initially, but then fails with
$ pip3 install PySide
You are using pip version 6.0.8, however version 8.1.0 is available.
You should consider upgrading via the 'pip install --upgrade pip' command.
Collecting PySide
Downloading PySide-1.2.4.tar.gz (9.3MB)
100% |################################| 9.3MB 69kB/s
Removing /private/var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-build-8_5xp5w9/PySide/pyside_package
package init file 'pyside_package/PySide/__init__.py' not found (or not a regular file)
package init file 'pyside_package/pysideuic/__init__.py' not found (or not a regular file)
Installing collected packages: PySide
Running setup.py install for PySide
Removing /private/var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-build-8_5xp5w9/PySide/pyside_package
Python architecture is 64bit
error: Failed to find cmake. Please specify the path to cmake with --cmake parameter.
Complete output from command /usr/local/opt/python3/bin/python3.4 -c "import setuptools, tokenize;__file__='/private/var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-build-8_5xp5w9/PySide/setup.py';exec(compile(getattr(tokenize, 'open', open)(__file__).read().replace('\r\n', '\n'), __file__, 'exec'))" install --record /var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-ugoc2jah-record/install-record.txt --single-version-externally-managed --compile:
Removing /private/var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-build-8_5xp5w9/PySide/pyside_package
running install
running build
Python architecture is 64bit
error: Failed to find cmake. Please specify the path to cmake with --cmake parameter.
----------------------------------------
Command "/usr/local/opt/python3/bin/python3.4 -c "import setuptools, tokenize;__file__='/private/var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-build-8_5xp5w9/PySide/setup.py';exec(compile(getattr(tokenize, 'open', open)(__file__).read().replace('\r\n', '\n'), __file__, 'exec'))" install --record /var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-ugoc2jah-record/install-record.txt --single-version-externally-managed --compile" failed with error code 1 in /private/var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-build-8_5xp5w9/PySide
Trying to run cmake
indicates it doesn't exist:
$ cmake
-bash: cmake: command not found
Maybe we can just install cmake
using our package manager (brew) and it'll be happy? Let's try brew install cmake
...
$ brew install cmake
==> Downloading http://www.cmake.org/files/v3.1/cmake-3.1.2.tar.gz
######################################################################## 100.0%
==> Downloading https://pypi.python.org/packages/source/S/Sphinx/Sphinx-1.2.3.tar.gz
######################################################################## 100.0%
==> python -c import setuptools... --no-user-cfg install --prefix=/private/tmp/cmake-Kp5igT/cmake-3.1
==> Downloading https://pypi.python.org/packages/source/d/docutils/docutils-0.12.tar.gz
######################################################################## 100.0%
==> python -c import setuptools... --no-user-cfg install --prefix=/private/tmp/cmake-Kp5igT/cmake-3.1
==> Downloading https://pypi.python.org/packages/source/P/Pygments/Pygments-2.0.2.tar.gz
######################################################################## 100.0%
==> python -c import setuptools... --no-user-cfg install --prefix=/private/tmp/cmake-Kp5igT/cmake-3.1
==> Downloading https://pypi.python.org/packages/source/J/Jinja2/Jinja2-2.7.3.tar.gz
######################################################################## 100.0%
==> python -c import setuptools... --no-user-cfg install --prefix=/private/tmp/cmake-Kp5igT/cmake-3.1
==> Downloading https://pypi.python.org/packages/source/M/MarkupSafe/MarkupSafe-0.23.tar.gz
######################################################################## 100.0%
==> python -c import setuptools... --no-user-cfg install --prefix=/private/tmp/cmake-Kp5igT/cmake-3.1
==> ./bootstrap --prefix=/usr/local/Cellar/cmake/3.1.2 --system-libs --parallel=8 --no-system-libarch
==> make
==> make install
/usr/local/Cellar/cmake/3.1.2: 1821 files, 29M, built in 5.1 minutes
After 5 long and painful minutes, it seems to have worked! And now the cmake
command prints something:
$ cmake
Usage
cmake [options] <path-to-source>
cmake [options] <path-to-existing-build>
Specify a source directory to (re-)generate a build system for it in the
current working directory. Specify an existing build directory to
re-generate its build system.
Run 'cmake --help' for more information.
Let's try pip3 install PySide
again...
$ pip3 install PySide
You are using pip version 6.0.8, however version 8.1.0 is available.
You should consider upgrading via the 'pip install --upgrade pip' command.
Collecting PySide
Using cached PySide-1.2.4.tar.gz
Removing /private/var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-build-dx05oahd/PySide/pyside_package
package init file 'pyside_package/PySide/__init__.py' not found (or not a regular file)
package init file 'pyside_package/pysideuic/__init__.py' not found (or not a regular file)
Installing collected packages: PySide
Running setup.py install for PySide
Removing /private/var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-build-dx05oahd/PySide/pyside_package
Python architecture is 64bit
error: Failed to find qmake. Please specify the path to qmake with --qmake parameter.
Complete output from command /usr/local/opt/python3/bin/python3.4 -c "import setuptools, tokenize;__file__='/private/var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-build-dx05oahd/PySide/setup.py';exec(compile(getattr(tokenize, 'open', open)(__file__).read().replace('\r\n', '\n'), __file__, 'exec'))" install --record /var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-ln1fsrej-record/install-record.txt --single-version-externally-managed --compile:
Removing /private/var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-build-dx05oahd/PySide/pyside_package
running install
running build
Python architecture is 64bit
error: Failed to find qmake. Please specify the path to qmake with --qmake parameter.
----------------------------------------
Command "/usr/local/opt/python3/bin/python3.4 -c "import setuptools, tokenize;__file__='/private/var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-build-dx05oahd/PySide/setup.py';exec(compile(getattr(tokenize, 'open', open)(__file__).read().replace('\r\n', '\n'), __file__, 'exec'))" install --record /var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-ln1fsrej-record/install-record.txt --single-version-externally-managed --compile" failed with error code 1 in /private/var/folders/xw/4lw_p32d0pg88hyg5rfn7vqxqhkgj_/T/pip-build-dx05oahd/PySide
What on earth is qmake
??? I certainly can't brew install
it:
$ brew install qmake
Error: No available formula for qmake
Searching formulae...
Searching taps...
Googling pyside qmake osx
gives:
It tells us to install Xcode, which I already have from earlier. After that, there's this magic command.
brew install python qt
We've already just installed cmake
, and have python
installed as part of the whole Anaconda thing earlier. I guess we just need qt to make the qmake
command available. brew install qt
...
$ brew install qt
==> Downloading https://download.qt.io/official_releases/qt/4.8/4.8.6/qt-everywhere-opensource-src-4.
curl: (22) The requested URL returned error: 403 Forbidden
Trying a mirror...
==> Downloading http://qtmirror.ics.com/pub/qtproject/official_releases/qt/4.8/4.8.6/qt-everywhere-op
######################################################################## 100.0%
==> Downloading https://raw.githubusercontent.com/DomT4/scripts/440e3cafde5bf6ec6f50cd28fa5bf89c280f1
######################################################################## 100.0%
==> Patching
patching file src/gui/dialogs/qcolordialog_mac.mm
patching file src/gui/dialogs/qfiledialog_mac.mm
patching file src/gui/dialogs/qfontdialog_mac.mm
patching file src/gui/kernel/qapplication_mac.mm
patching file src/gui/kernel/qcocoaapplication_mac.mm
patching file src/gui/kernel/qcocoaapplicationdelegate_mac.mm
Hunk #4 succeeded at 295 (offset -13 lines).
Hunk #5 succeeded at 342 (offset -13 lines).
patching file src/gui/kernel/qcocoaapplicationdelegate_mac_p.h
patching file src/gui/kernel/qcocoamenuloader_mac.mm
patching file src/gui/kernel/qcocoasharedwindowmethods_mac_p.h
patching file src/gui/kernel/qeventdispatcher_mac.mm
patching file src/gui/kernel/qt_cocoa_helpers_mac.mm
patching file src/gui/kernel/qwidget_mac.mm
patching file src/gui/styles/qmacstyle_mac.mm
patching file src/gui/util/qsystemtrayicon_mac.mm
patching file src/gui/widgets/qcocoamenu_mac.mm
patching file src/gui/widgets/qmenu_mac.mm
==> ./configure -prefix /usr/local/Cellar/qt/4.8.6 -system-zlib -qt-libtiff -qt-libpng -qt-libjpeg -c
==> make
2 errors generated.
make[2]: *** [.obj/release-shared/qpaintengine_mac.o] Error 1
make[2]: *** Waiting for unfinished jobs....
make[1]: *** [release] Error 2
make: *** [sub-gui-make_default-ordered] Error 2
READ THIS: http://git.io/brew-troubleshooting
Seems like installation timed out or something? Trying to run qmake
certainly doesn't work yet. Googling brew install qt timing out
gives us:
https://github.com/Homebrew/homebrew/issues/30843
So it seems like it's not just our problem. Maybe running it again will work? If not we might go back and try installing PyQt5
again using the long-ish instructions we found earlier.
It seems to have failed again:
==> ./configure -prefix /usr/local/Cellar/qt/4.8.6 -system-zlib -qt-libtiff -qt-libpng -qt-libjpeg -c
==> make
2 errors generated.
make[2]: *** [.obj/release-shared/qpaintengine_mac.o] Error 1
make[2]: *** Waiting for unfinished jobs....
make[1]: *** [release] Error 2
make: *** [sub-gui-make_default-ordered] Error 2
READ THIS: http://git.io/brew-troubleshooting
Let's give up on PySide for now. I don't know why the installation doesn't work but it doesn't. It seems to require qt
anyway which is probably not that different from what PyQt4
requires.
Googling again for "osx PyQt5", I find
This package installs a prebuilt version of PyQt5 for Mac OS. It does not compile anything because uses an already precompiled one.
I wonder if that would work? Avoiding compilation would certainly avoid the what-looks-like-a-compilation-timeout during the make
command that was running while trying to brew install qt
. Presumably any mechanism of trying to install PyQt5
that needs compilation would need qt
and bump into the same timeout? Anyway this seems to have installed successfully:
$ pip3 install pyqt5-macos-built
You are using pip version 6.0.8, however version 8.1.0 is available.
You should consider upgrading via the 'pip install --upgrade pip' command.
Collecting pyqt5-macos-built
Downloading pyqt5-macos-built-5.5.0.tar.gz (5.1MB)
100% |################################| 5.1MB 124kB/s
/usr/local/Cellar/python3/3.4.2_1/Frameworks/Python.framework/Versions/3.4/lib/python3.4/distutils/dist.py:260: UserWarning: Unknown distribution option: 'bugtrack_url'
warnings.warn(msg)
Collecting docopt (from pyqt5-macos-built)
Downloading docopt-0.6.2.tar.gz
Installing collected packages: docopt, pyqt5-macos-built
Running setup.py install for docopt
Running setup.py install for pyqt5-macos-built
changing mode of build/scripts-3.4/pyqt5_macos_built.py from 644 to 755
/usr/local/Cellar/python3/3.4.2_1/Frameworks/Python.framework/Versions/3.4/lib/python3.4/distutils/dist.py:260: UserWarning: Unknown distribution option: 'bugtrack_url'
warnings.warn(msg
changing mode of /usr/local/bin/pyqt5_macos_built.py to 755
Successfully installed docopt-0.6.2 pyqt5-macos-built-5.5.0
Oddly enough, trying to run python3 bootstrap.py
seems to be failing claiming it can't find PyQt5
, even though in the python3
repl it imports just fine!
$ python3 bootstrap.py
Executing Spyder from source checkout
Revision df9577c, Branch: master
01. Patched sys.path with /Users/haoyi/test/spyder
02. PyQt5 is detected, selecting
Traceback (most recent call last):
File "/Users/haoyi/test/spyder/spyderlib/qt/__init__.py", line 62, in <module>
from PySide import __version__ # analysis:ignore
ImportError: No module named 'PySide'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "bootstrap.py", line 140, in <module>
versions = get_versions(reporev=False)
File "/Users/haoyi/test/spyder/spyderlib/__init__.py", line 65, in get_versions
import spyderlib.qt
File "/Users/haoyi/test/spyder/spyderlib/qt/__init__.py", line 65, in <module>
raise ImportError("Spyder requires PyQt5, PyQt4 or PySide (deprecated) "
ImportError: Spyder requires PyQt5, PyQt4 or PySide (deprecated) to be installed
$ python3
Python 3.4.2 (default, Feb 10 2015, 03:38:22)
[GCC 4.2.1 Compatible Apple LLVM 6.0 (clang-600.0.56)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import PyQt5
>>>
Looking at the code performing the import in spyderlib/qt/__init__.py
:
if API == 'pyqt5':
try:
from PyQt5.QtCore import PYQT_VERSION_STR as __version__
from PyQt5 import uic # analysis:ignore
except ImportError:
API = os.environ['QT_API'] = 'pyqt'
API_NAME = 'PyQt4'
Seems like it's running into an ImportError
that's resulting in a failure down below in the same file? We can try running those imports in the python3
REPL, and lo-and-behold they fail:
$ python3
Python 3.4.2 (default, Feb 10 2015, 03:38:22)
[GCC 4.2.1 Compatible Apple LLVM 6.0 (clang-600.0.56)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from PyQt5.QtCore import PYQT_VERSION_STR as __version__
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ImportError: dlopen(/usr/local/lib/python3.4/site-packages/PyQt5/QtCore.so, 2): Library not loaded: /usr/local/opt/qt5/lib/QtCore.framework/Versions/5/QtCore
Referenced from: /usr/local/lib/python3.4/site-packages/PyQt5/QtCore.so
Reason: image not found
>>>
No luck; even though it seems you can import PyQt5
, doing from PyQt5.Core import PYQT_VERSION_STR as __version__
seems to blow up. Seems like some binary dependency was not properly installed? Maybe the pyqt5-macos-built
package is missing some QT dependency it needs.
Googling "Library not loaded QtCore" find us:
Maybe if brew install qt
doesn't work then brew install qt5
will???
This seems to be taking forever. It turns out that you can install python packages through conda
as well as through pip
. Googling "Anaconda pyqt5" gives us
conda config --add channels dsdale24
conda install pyqt5
Which appears not to work due to python3 being something something incompatible with pyqt5 something something. Turns out that the Anaconda package for pyqt5
only works with Python 2.7!
$ conda info pyqt5
Using Anaconda Cloud api site https://api.anaconda.org
Fetching package metadata: ......
pyqt5 5.3.1 py27_0
------------------
...
Luckily conda
has a lot of useful commands, including a search
command that could help us find something similar that might work:
$ conda
usage: conda [-h] [-V] [--debug] command ...
conda is a tool for managing and deploying applications, environments and packages.
Options:
positional arguments:
command
info Display information about current conda install.
help Displays a list of available conda commands and their help
strings.
list List linked packages in a conda environment.
search Search for packages and display their information. The input
is a Python regular expression. To perform a search with a
search string that starts with a -, separate the search from
the options with --, like 'conda search -- -h'. A * in the
results means that package is installed in the current
environment. A . means that package is not installed but is
cached in the pkgs directory.
Could it find a version of pyqt
that would work with Python 3???
$ conda search pyqt
Using Anaconda Cloud api site https://api.anaconda.org
Fetching package metadata: ......
pyqt 4.10.4 py26_0 defaults
4.10.4 py27_0 defaults
4.10.4 py33_0 defaults
4.10.4 py34_0 defaults
4.11.3 py26_0 defaults
4.11.3 py27_0 defaults
4.11.3 py33_0 defaults
4.11.3 py34_0 defaults
4.11.3 py35_0 defaults
4.11.4 py27_0 defaults
4.11.4 py34_0 defaults
4.11.4 py35_0 defaults
4.11.4 py27_1 defaults
4.11.4 py34_1 defaults
* 4.11.4 py35_1 defaults
pyqt5 5.3.1 py27_0 dsdale24
pyqtgraph 0.9.10 py27_0 defaults
0.9.10 py34_0 defaults
0.9.10 py35_0 defaults
0.9.10 py27_1 defaults
0.9.10 py34_1 defaults
0.9.10 py35_1 defaults
pyqt
seems to have packages for py35_0
(which is where we are) even though pyqt5
doesn't. Maybe we should install that instead?
Using pyqt
(which is version 4) seems to work:
$ conda install pyqt
Using Anaconda Cloud api site https://api.anaconda.org
Fetching package metadata: ......
Solving package specifications: ..........................
Package plan for installation in environment /Users/haoyi/anaconda3:
The following packages will be downloaded:
package | build
---------------------------|-----------------
openssl-1.0.2g | 0 3.0 MB
xz-5.0.5 | 1 173 KB
setuptools-20.2.2 | py35_0 458 KB
wheel-0.29.0 | py35_0 82 KB
conda-4.0.4 | py35_0 188 KB
pip-8.1.0 | py35_0 1.6 MB
------------------------------------------------------------
Total: 5.4 MB
The following packages will be UPDATED:
conda: 3.19.1-py35_0 --> 4.0.4-py35_0
openssl: 1.0.2f-0 --> 1.0.2g-0
pip: 8.0.2-py35_0 --> 8.1.0-py35_0
setuptools: 19.6.2-py35_0 --> 20.2.2-py35_0
wheel: 0.26.0-py35_1 --> 0.29.0-py35_0
xz: 5.0.5-0 --> 5.0.5-1
Proceed ([y]/n)? y
Fetching packages ...
openssl-1.0.2g 100% |#####################################################| Time: 0:00:08 393.54 kB/s
xz-5.0.5-1.tar 100% |#####################################################| Time: 0:00:00 184.22 kB/s
setuptools-20. 100% |#####################################################| Time: 0:00:01 238.96 kB/s
wheel-0.29.0-p 100% |#####################################################| Time: 0:00:00 102.15 kB/s
conda-4.0.4-py 100% |#####################################################| Time: 0:00:00 199.95 kB/s
pip-8.1.0-py35 100% |#####################################################| Time: 0:00:02 673.73 kB/s
Extracting packages ...
[ COMPLETE ]|########################################################################| 100%
Unlinking packages ...
[ COMPLETE ]|########################################################################| 100%
Linking packages ...
[ COMPLETE ]|########################################################################| 100%
$
Now it seems to not crash when I run the bootstrap script!
$ python3 bootstrap.py
Executing Spyder from source checkout
Revision df9577c, Branch: master
01. Patched sys.path with /Users/haoyi/test/spyder
02. PyQt4 is detected, selecting
03. Imported Spyder 3.0.0dev
[Python 3.5.1 64bits, Qt 4.8.7, PyQt4 (API v2) 4.11.4 on Darwin]
04. Running Spyder
Or not
Bootstrap completed in 00:00:13.3716
Traceback (most recent call last):
File "bootstrap.py", line 162, in <module>
start.main()
File "/Users/haoyi/test/spyder/spyderlib/app/start.py", line 118, in main
from spyderlib.app import spyder
File "/Users/haoyi/test/spyder/spyderlib/app/spyder.py", line 99, in <module>
from spyderlib.utils.qthelpers import qapplication
File "/Users/haoyi/test/spyder/spyderlib/utils/qthelpers.py", line 16, in <module>
import spyderlib.utils.icon_manager as ima
File "/Users/haoyi/test/spyder/spyderlib/utils/icon_manager.py", line 14, in <module>
import qtawesome as qta
ImportError: No module named 'qtawesome'
What on earth is that? I can't seem to conda install
it, but the build-in search pulls up something that looks promising
$ conda install qtawesome
Using Anaconda Cloud api site https://api.anaconda.org
Fetching package metadata: ......
Solving package specifications: .
Error: Package missing in current osx-64 channels:
- qtawesome
You can search for this package on anaconda.org with
anaconda search -t conda qtawesome
$ anaconda search -t conda qtawesome
Using Anaconda Cloud api site https://api.anaconda.org
Run 'anaconda show <USER/PACKAGE>' to get more details:
Packages:
Name | Version | Package Types | Platforms
------------------------- | ------ | --------------- | ---------------
lightmotif/qtawesome | 0.1.10 | conda | win-32
: FontAwesome icons in PyQt and PySide applications
spyder-ide/qtawesome | 0.2.0 | conda | None-None
: Iconic fonts in PyQt and PySide applications
Found 2 packages
I wonder how I can install that?
Googling qtawesome
pulls this up:
Seems like some kind of font-awesome-for-Qt thing. I guess they use it to make the arrows and stuff look nice.
Installing using pip3
seems to work. I guess conda install
and pip3
install are diferent some how? Anyway pip3
reports success:
$ pip3 install QtAwesome
You are using pip version 6.0.8, however version 8.1.0 is available.
You should consider upgrading via the 'pip install --upgrade pip' command.
Collecting QtAwesome
Downloading QtAwesome-0.3.1-py2.py3-none-any.whl (149kB)
100% |################################| 151kB 2.7MB/s
Collecting qtpy (from QtAwesome)
Downloading QtPy-0.1.3-py2.py3-none-any.whl
Collecting six (from QtAwesome)
Using cached six-1.10.0-py2.py3-none-any.whl
Installing collected packages: six, qtpy, QtAwesome
Successfully installed QtAwesome-0.3.1 qtpy-0.1.3 six-1.10.0
But it doesn't actually work when I try to import it.
$ python3
Python 3.5.1 |Anaconda 2.5.0 (x86_64)| (default, Dec 7 2015, 11:24:55)
[GCC 4.2.1 (Apple Inc. build 5577)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import qtawesome
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ImportError: No module named 'qtawesome'
>>>
Googling conda install "qtawesome"
(note the quotes are necessary to avoid google splitting it into two words!) brings up this:
Which doesn't work:
$ conda install -c https://conda.anaconda.org/lightmotif qtawesome
Using Anaconda Cloud api site https://api.anaconda.org
Fetching package metadata: ........
Solving package specifications: .
Error: Package missing in current osx-64 channels:
- qtawesome
You can search for this package on anaconda.org with
anaconda search -t conda qtawesome
but this does though, using the "other" foo/qtawesome
name that was listed by the anaconda search
earlier:
$ conda install -c https://conda.anaconda.org/spyder-ide qtawesome
Using Anaconda Cloud api site https://api.anaconda.org
Fetching package metadata: ........
Solving package specifications: ..........
Package plan for installation in environment /Users/haoyi/anaconda3:
The following packages will be downloaded:
package | build
---------------------------|-----------------
qtpy-1.0b1 | py_0 10 KB
qtawesome-0.2.0 | py_0 146 KB
------------------------------------------------------------
Total: 156 KB
The following NEW packages will be INSTALLED:
qtawesome: 0.2.0-py_0
qtpy: 1.0b1-py_0
Proceed ([y]/n)? y
Fetching packages ...
qtpy-1.0b1-py_ 100% |#####################################################| Time: 0:00:00 38.93 kB/s
qtawesome-0.2. 100% |#####################################################| Time: 0:00:01 106.19 kB/s
Extracting packages ...
[ COMPLETE ]|########################################################################| 100%
Linking packages ...
[ COMPLETE ]|########################################################################| 100%
And now after python3 bootstrap.py
, it's alive!
Or not
Bootstrap completed in 00:00:01.8318
Traceback (most recent call last):
File "/Users/haoyi/test/spyder/spyderlib/app/spyder.py", line 3130, in main
mainwindow = run_spyder(app, options, args)
File "/Users/haoyi/test/spyder/spyderlib/app/spyder.py", line 3026, in run_spyder
main.post_visible_setup()
File "/Users/haoyi/test/spyder/spyderlib/app/spyder.py", line 1316, in post_visible_setup
self.report_missing_dependencies()
File "/Users/haoyi/test/spyder/spyderlib/app/spyder.py", line 1322, in report_missing_dependencies
missing_deps = dependencies.missing_dependencies()
File "/Users/haoyi/test/spyder/spyderlib/dependencies.py", line 108, in missing_dependencies
if not dependency.check() and not dependency.optional:
File "/Users/haoyi/test/spyder/spyderlib/dependencies.py", line 46, in check
self.installed_version)
File "/Users/haoyi/test/spyder/spyderlib/utils/programs.py", line 429, in is_module_installed
actver = get_module_version(module_name)
File "/Users/haoyi/test/spyder/spyderlib/utils/programs.py", line 371, in get_module_version
mod = __import__(module_name)
File "/Users/haoyi/anaconda3/lib/python3.5/site-packages/jedi/__init__.py", line 41, in <module>
from jedi.api import Script, Interpreter, NotFoundError, set_debug_function
File "/Users/haoyi/anaconda3/lib/python3.5/site-packages/jedi/api/__init__.py", line 16, in <module>
from jedi.parser import Parser, load_grammar
File "/Users/haoyi/anaconda3/lib/python3.5/site-packages/jedi/parser/__init__.py", line 21, in <module>
from jedi.parser import tree as pt
File "/Users/haoyi/anaconda3/lib/python3.5/site-packages/jedi/parser/tree.py", line 39, in <module>
from jedi import cache
File "/Users/haoyi/anaconda3/lib/python3.5/site-packages/jedi/cache.py", line 32, in <module>
from jedi import debug
File "/Users/haoyi/anaconda3/lib/python3.5/site-packages/jedi/debug.py", line 17, in <module>
init()
File "/Users/haoyi/anaconda3/lib/python3.5/site-packages/colorama/initialise.py", line 37, in init
wrap_stream(orig_stdout, convert, strip, autoreset, wrap)
File "/Users/haoyi/anaconda3/lib/python3.5/site-packages/colorama/initialise.py", line 76, in wrap_stream
convert=convert, strip=strip, autoreset=autoreset)
File "/Users/haoyi/anaconda3/lib/python3.5/site-packages/colorama/ansitowin32.py", line 67, in __init__
strip = conversion_supported or (not wrapped.closed and not is_a_tty(wrapped))
AttributeError: 'SysOutput' object has no attribute 'closed'
Googling the error message brings us to
Seems the solution is one of either
pip install colorama==0.3.5
conda install colorama=0.3.3
Depending on which of the people posting on that issue you believe. The first one doesn't seem to work, but the second one does. Spyder boots up!
And shows:
But that's a simple conda install pylint
away, and then it works!
I notice that if I leave it running long enough (2-3 minutes?) it dies with
Segmentation fault: 11
So it's not totally stable. But it's stable enough for us to run and test out changes, so for now, this is good enough!
By now about 2 hours have passed since I first started trying to make a change to the Spyder "Scientific Python" IDE. We finally managed to get it booting up under "development" mode from source, which is the most important step in trying to understand a foreign codebase. So far, we've only glanced at the code once or twice to try and figure out why dependencies didn't work; the next step would be to dive into the code in order to figure out what's what.
As mentioned earlier, digging through the code of an unfamiliar project happens in stages: the first stage is usually a coarse-grained browsing of the various files and folders, seeing what's where and what kinds of "stuff" the project contains. Only after that do you dive into looking for the specific "thing" that you want.
The main top-folders that appear to be in the Spyder project are:
app_example/
: obviously some kind of example application, probably not the main codeconda.recipe/
: some kind of integration with Anaconda, to let people install Spyder easilycontinuous_integration/
: something related to running automated unit testsdoc/
: documentation site?img_src/
: no idearope_profiling/
: rope
is the name of a Python AST processing/code-rewriting library. No idea why it needs to be profiledscripts/
: probably ad-hoc things that developers run that aren't part of the main codespyderlib/
: the IDE itself, as a library. Most of the code is probably herespyplugins/
: plugins to the IDE; depending on what the architecture looks like, this could contain minor tweaks, or it could include whoel swathes of IDE functionality the way plugins work in IntelliJ.Another thing to do is to simply do a line-count of the code that exists in the repository. This doesn't give us a huge amount of detail, but it does tell us what kind of beast are we looking at. Thousands of lines of code? Tens of thousands? Millions?
$ find . -name "*.py" | xargs wc -l
...
58606 total
58 thousand lines of Python. Not enormous, but not a small codebase by any means! We can quickly confirm that most of the "stuff" is in the spyderlib/
folder:
find spyderlib -name "*.py" | xargs wc -l
...
54799 total
Poking around spyderlib/
in a file browser, it becomes pretty obvious what the main folders inside contain:
app/
: seems to contain the "entry point" code that kicks off the Spyder IDE. In theory, when you run Spyder, this is the first file that should be loaded.config/
& defaults/
: the code related to loading user-definable configuration from files, and the defaults for all the configuration if not set.fonts/
& images/
: non-code resources necessary to make the IDE look prettylocale/
: translations and translation-related stuff. Seems they support Spanish (es/
), French (fr/
) and Brazilian Portuguese (pt_BR/
)plugins/
: probably parts of the code that can easily be included or excluded, enabled/disabled, since that's what plugins typically are.qt/
: seems to be mostly shims that swap between the PyQt4
/PyQt5
/PySide
, GUI libraries. Seems to import the one that works for the rest of the code to use. Doesn't seem to actually define any UI code itself.utils/
: a grab bag of random functionality that probably doesn't depend on anything else in the codebase. Also contains a whole pile of Javascript/HTML/CSS code to render some kind of help sitewidgets/
: the real building blocks of the Spyder GUI. Each file appears to contain one individual widget: a file-switcher widget, a find-replace widget, and so onwindows/
: contains two icons to put on the top-left of the window when running on Windowsworkers/
: seems like it should contain all sorts of asynchronous task workers, but only contains code for a single worker that polls githubThat's probably enough for a rough tour for now. For the next stage in familiarization, we will dig into one particular change that we want to make. This should let us exercise some of the knowledge that we just gained by browsing through the codebase, as well as learn deeper things about it.
The next step, now that we've got the code running and have gone through at a high level, is to make a "hello world" change to make sure that we are indeed able to make changes to the code and execute the code with our changes. One common hello world change is to change some text somewhere in the application. For example, let's change the title of the window:
From Spyder 3.0.0dev (Python 3.5)
to, say, Spyder Hello World...
.
This thing looks like a title. The same thing (label at top of window) is called the title
in HTML, Java Swing, and every other GUI framework I've used, so I'd bet it's called title
here too. Let's search the codebase for title
:
460 occurrences! That seems like a lot! But actually it really isn't. You should be able to skim through the 460 occurrences in less than 5 minutes. Many of them are setting the title on various modals, some others are titles in other languages such as HTML. Nevertheless, we should glance every find: it's entirely possible that the title is coming from some config .ini
or .json
file or similar and isn't being explicitly set in .py
code at all!
In this case, we're lucky and this seems like what we want:
If we weren't lucky, we'd have to try other keywords to search, or even start tracing through the execution of the code to find where it starts instantiating the GUI. But we were lucky, and so found the relevant code in less than 5 minutes:
# Set Window title and icon
if DEV is not None:
title = "Spyder %s (Python %s.%s)" % (__version__,
sys.version_info[0],
sys.version_info[1])
else:
title = "Spyder (Python %s.%s)" % (sys.version_info[0],
sys.version_info[1])
if DEBUG:
title += " [DEBUG MODE %d]" % DEBUG
Browsing around, this file appears to be both the entry-point where Spyder starts, as well as a huge mass of logic related what the Spyder IDE does. At 3168 lines of code, it's definitely not just an initialization stub! It seems to be registering shortcuts with self.register_shortcut
, fiddling with OS signals, working with sessions (whatever those are in the context of an IDE), fiddling with env
variables, and waaaay at the bottom of the three thousand lines we see the signature of a Python program entry-point:
if __name__ == "__main__":
main()
Coming back to the snippet of code we found, changing the title to Spyder Hello World...
is a matter of tweaking the string:
$ git diff
diff --git a/spyderlib/app/spyder.py b/spyderlib/app/spyder.py
index 6d9763f..830e8f3 100644
--- a/spyderlib/app/spyder.py
+++ b/spyderlib/app/spyder.py
@@ -436,7 +436,7 @@ class MainWindow(QMainWindow):
# Set Window title and icon
if DEV is not None:
- title = "Spyder %s (Python %s.%s)" % (__version__,
+ title = "Spyder Hello World %s (Python %s.%s)" % (__version__,
sys.version_info[0],
sys.version_info[1])
else:
And re-running python bootstrap.py
. And there we have it
Our first change works!
The initial problem statement was that we were to add a new menu dropdown at the top of the screen, next to the existing Help
menu:
One way to find where those menus are being defined is to search for the word menu
within the codebase. Assuming the developers use the same vocabulary as you thought they do, that should pull it up as part of a larger search that you could trawl through to find it. Another technique is to search for a string that's part of those menus, that's hopefully long enough to be unique, and that should bring us to the definition of that string right away.
In this case, I'm going to search for Consoles
within the codebase, with case sensitive search on, in the hope that there won't be that many places where the Consoles
string turns up: after all, in Python variables and module-names would all be lower-case, and non-Python usages (e.g. HTML docs) or comments are easy to ignore. That brings up about a dozen results, mostly in .md
and .rst
and .po
files
Unusually, none of them initially appear to be what I want: they're mostly parts of long help-pages, or comments. However, one of the comments seems suspicious:
# Consoles menu/toolbar
Looking at that code, we see:
# Debug menu/toolbar
self.debug_menu = self.menuBar().addMenu(_("&Debug"))
self.debug_toolbar = self.create_toolbar(_("Debug toolbar"),
"debug_toolbar")
# Consoles menu/toolbar
self.consoles_menu = self.menuBar().addMenu(_("C&onsoles"))
# Tools menu
self.tools_menu = self.menuBar().addMenu(_("&Tools"))
Aha! That seems like what we want! This is the code that is hard-coded to append all the dropdown-menus to the toolbar. For some reason there's this weird &
character before or in the middle of all the names; that's probably some magic syntax to make it tread the next character as the shortcut key for that menu. It doesn't seem to work on OS-X, but on the original screenshot presumably on Windows you can see the underlined characters indicating the hotkeys all in the right places:
From there it's relatively straightforward to add a new menu to the menu bar:
$ git diff
diff --git a/spyderlib/app/spyder.py b/spyderlib/app/spyder.py
index 6d9763f..c855220 100644
--- a/spyderlib/app/spyder.py
+++ b/spyderlib/app/spyder.py
@@ -663,6 +663,9 @@ class MainWindow(QMainWindow):
# Help menu
self.help_menu = self.menuBar().addMenu(_("&Help"))
+ # Help menu
+ self.misc_menu = self.menuBar().addMenu(_("&Misc"))
+
# Status bar
status = self.statusBar()
status.setObjectName("StatusBar")
And there we have it, a Misc
menu!
Currently, this menu doesn't seem to do anything. Clicking on it doesn't even drop down a list of menu items. Presumably that's because there aren't even any items in that menu yet; we haven't added any. The next step would be to figure out how every other menu is adding menu items, and copying them to add our own menu items.
Clearly the menu items are not added at the place we found above; that simply initializes each dropdown menu with a name. They have to be added elsewhere. We can easily search for the use sites of a menu to see where it's being used, and if anyone seems to be adding stuff to it. e.g. searching for the use sites of console_menu
:
Immediately, we can see that apart from the code initializing the self.consoles_menu
, there is also code setting it to None
, code setting self.console_menu_actions
to None
, and code calling this seemingly-relevant add_actions
function on self.consoles_menu
and self.consoles_menu__actions
. That's great. We can call the same thing. But consoles_menu_actions
starts off empty, and what on earth is going into it? We can easily search and find out:
Clearly it's a list with an interpreter_action
in it. Where does that come from? It's relatively trivial to Cmd-F
in that file and find it:
interpreter_action = create_action(self,
_("Open a &Python console"), None,
ima.icon('python'),
triggered=self.open_interpreter)
if os.name == 'nt':
text = _("Open &command prompt")
tip = _("Open a Windows command prompt")
else:
text = _("Open a &terminal")
tip = _("Open a terminal window")
terminal_action = create_action(self, text, None, None, tip,
triggered=self.open_terminal)
run_action = create_action(self,
_("&Run..."), None,
ima.icon('run_small'), _("Run a Python script"),
triggered=self.run_script)
consoles_menu_actions = [interpreter_action]
Seems relatively straightforward: you call create_action
, pass in self
(whatever that is), some kind of _(...)
-wrapped string, None
, some icon, and a triggered
argument that looks like the callback. We can easily jump to (or grep for) the definition of create_action
, find it in spyderlib/utils/qthelpers.py
, and see what it does:
def create_action(parent, text, shortcut=None, icon=None, tip=None,
toggled=None, triggered=None, data=None, menurole=None,
context=Qt.WindowShortcut):
action = QAction(text, parent)
if triggered is not None:
action.triggered.connect(triggered)
if toggled is not None:
action.toggled.connect(toggled)
action.setCheckable(True)
...
return action
Seems like the None
being passed in is that shortcut; I guess there isn't any shortcut for Open a Python Console
? I don't know what the data
or menurole
arguments do but I probably don't need to for now. Next thing to look at would be the self.open_interpreter
function that's being passed in:
@Slot(str)
def open_interpreter(self, wdir=None):
"""Open interpreter"""
if not wdir:
wdir = getcwd()
self.visibility_changed(True)
self.start(fname=None, wdir=to_text_string(wdir), args='',
interact=True, debug=False, python=True)
No clue what that @Slot(str)
thing is, but it appears that each callback (e.g. the run_action
shown in the above snippet) all have a @Slots
decorator that matches the number of arguments the callback takes. I guess we can just follow that convention and it should keep working.
Otherwise it seems relatively straightforward: it calls some methods that do things. That's great. I know how to write functions that call methods to do things too!
We've seen where the foo_menu_actions
lists get created, where they get appended to, where they get passed in to add_actions
to append them to the menus themselves. It now isn't hard to monkey what they're doing to get our own custom action plumbed through the flow:
$ git --no-pager diff
diff --git a/spyderlib/app/spyder.py b/spyderlib/app/spyder.py
index 6d9763f..44fb373 100644
--- a/spyderlib/app/spyder.py
+++ b/spyderlib/app/spyder.py
@@ -408,6 +408,8 @@ class MainWindow(QMainWindow):
self.toolbars_menu = None
self.help_menu = None
self.help_menu_actions = []
+ self.misc_menu_actions = []
+
# Status bar widgets
self.mem_status = None
@@ -663,6 +665,9 @@ class MainWindow(QMainWindow):
# Help menu
self.help_menu = self.menuBar().addMenu(_("&Help"))
+ # Misc menu
+ self.misc_menu = self.menuBar().addMenu(_("&Misc"))
+
# Status bar
status = self.statusBar()
status.setObjectName("StatusBar")
@@ -1181,6 +1186,8 @@ class MainWindow(QMainWindow):
self.external_tools_menu_actions)
add_actions(self.help_menu, self.help_menu_actions)
+ add_actions(self.misc_menu, self.misc_menu_actions)
+
add_actions(self.main_toolbar, self.main_toolbar_actions)
add_actions(self.file_toolbar, self.file_toolbar_actions)
add_actions(self.edit_toolbar, self.edit_toolbar_actions)
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 9b8ba50..391958a 100644
--- a/spyderlib/plugins/externalconsole.py
+++ b/spyderlib/plugins/externalconsole.py
@@ -1021,6 +1021,13 @@ class ExternalConsole(SpyderPluginWidget):
_("Open a &Python console"), None,
ima.icon('python'),
triggered=self.open_interpreter)
+ misc_action = create_action(self,
+ _("&Do Something"), None,
+ ima.icon('python'),
+ triggered=self.misc_action)
+
+ self.misc_menu_actions = [misc_action]
+
if os.name == 'nt':
text = _("Open &command prompt")
tip = _("Open a Windows command prompt")
@@ -1180,6 +1187,12 @@ class ExternalConsole(SpyderPluginWidget):
self.start(fname=None, wdir=to_text_string(wdir), args='',
interact=True, debug=False, python=True)
+ @Slot()
+ def misc_action(self):
+ """Open interpreter"""
+ print("Hello World Action")
+
+
def start_ipykernel(self, client, wdir=None, give_focus=True):
"""Start new IPython kernel"""
if not self.get_option('monitor/enabled'):
And we can run python3 bootstrap.py
and... it doesn't work! Out Misc
menu still doesn't drop down any clickable actions when we click on it! It turns out we missed a few spots when trying to monkey the existing code and copied them badly:
$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 6e7c548..d169e48 100644
--- a/spyderlib/plugins/externalconsole.py
+++ b/spyderlib/plugins/externalconsole.py
@@ -1026,7 +1026,7 @@ class ExternalConsole(SpyderPluginWidget):
ima.icon('python'),
triggered=self.misc_action)
- self.misc_menu_actions = [misc_action]
+ self.main.misc_menu_actions = [misc_action]
if os.name == 'nt':
text = _("Open &command prompt")
@@ -1048,7 +1048,7 @@ class ExternalConsole(SpyderPluginWidget):
self.main.consoles_menu_actions += consoles_menu_actions
self.main.tools_menu_actions += tools_menu_actions
- return self.menu_actions+consoles_menu_actions+tools_menu_actions
+ return self.menu_actions+consoles_menu_actions+tools_menu_actions+[misc_action]
def register_plugin(self):
"""Register plugin in Spyder's main window"""
And it works!
And you can click on it, except... the print statement doesn't seem to be going anywhere. Or at least, it isn't turning up in the console, which still says
$ python3 bootstrap.py
Executing Spyder from source checkout
Revision e978c9c, Branch: master
01. Patched sys.path with /Users/haoyi/test/spyder
02. PyQt4 is detected, selecting
03. Imported Spyder 3.0.0dev
[Python 3.5.1 64bits, Qt 4.8.7, PyQt4 (API v2) 4.11.4 on Darwin]
04. Running Spyder
Bootstrap completed in 00:00:01.2533
Even after clicking the action. It's plausible that the Spyder IDE redirects print statements to some log file, but I don't see any in the repository tree. There is a ~/.spyder-py3
folder in my home directory, but again I don't see anything that looks like a log file:
$ tree ~/.spyder-py3/
/Users/haoyi/.spyder-py3/
├── db
│ └── submodules
├── defaults
│ └── defaults-26.1.0.ini
├── history.py
├── history_internal.py
├── langconfig
├── onlinehelp
├── spyder.ini
├── spyder.ini.bak
├── spyder.lock -> 54811
├── spyplugins
│ ├── __init__.py
│ ├── io
│ │ └── __init__.py
│ └── ui
│ └── __init__.py
├── temp.py
├── template.py
└── workingdir
One option that we could try to use to make sure our code is getting run is to write to a file instead of printing to stdout:
$ git diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 94fe765..6a7ca15 100644
--- a/spyderlib/plugins/externalconsole.py
+++ b/spyderlib/plugins/externalconsole.py
@@ -1191,7 +1191,8 @@ class ExternalConsole(SpyderPluginWidget):
def misc_action(self):
"""Open interpreter"""
- print("Hello World Action")
+ with open("logfile.txt", "w") as f:
+ f.write("Hello World misc_action")
def start_ipykernel(self, client, wdir=None, give_focus=True):
but when I do so and click the Do something
action, I still can't find any log file anywhere on my filesystem using find / -name "logfile.txt"
. Does that mean our painstakingly crafted callback is not running? What if we copy the open_interpreter
call word for word?
$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 94fe765..4041ac3 100644
--- a/spyderlib/plugins/externalconsole.py
+++ b/spyderlib/plugins/externalconsole.py
@@ -1187,12 +1187,13 @@ class ExternalConsole(SpyderPluginWidget):
self.start(fname=None, wdir=to_text_string(wdir), args='',
interact=True, debug=False, python=True)
- @Slot()
- def misc_action(self):
- """Open interpreter"""
-
- print("Hello World Action")
-
+ @Slot(str)
+ def misc_action(self, wdir=None):
+ """Open terminal"""
+ if not wdir:
+ wdir = getcwd()
+ self.start(fname=None, wdir=to_text_string(wdir), args='',
+ interact=True, debug=False, python=False)
def start_ipykernel(self, client, wdir=None, give_focus=True):
"""Start new IPython kernel"""
Sure enough, when you click Do something
this opens up a new interpreter pane! What if I add a print
statement now?
$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 4041ac3..374c04b 100644
--- a/spyderlib/plugins/externalconsole.py
+++ b/spyderlib/plugins/externalconsole.py
@@ -1192,6 +1192,7 @@ class ExternalConsole(SpyderPluginWidget):
"""Open terminal"""
if not wdir:
wdir = getcwd()
+ print("HELLO WORLD misc_action")
self.start(fname=None, wdir=to_text_string(wdir), args='',
interact=True, debug=False, python=False)
Now, Do something
opens up a new interpreter pane, and still does not print anything. Presumably our code was running all along, but somehow print
and file-output was being redirected by Spyder... somewhere. I'm not sure how we're going to debug things going forward, but at least we know our code works for now and is running!
The last thing we need to do to accomplish our initial goal is to make modifications to the code editor. A cursory search through the filesystem reveals a file promisingly named widgets/editor.py
:
# -*- coding: utf-8 -*-
#
# Copyright © 2009- The Spyder Development Team
# Licensed under the terms of the MIT License
# (see spyderlib/__init__.py for details)
"""Editor Widget"""
# pylint: disable=C0103
# pylint: disable=R0903
# pylint: disable=R0911
# pylint: disable=R0201
from __future__ import print_function
from spyderlib.qt import is_pyqt46
from spyderlib.qt.QtGui import (QVBoxLayout, QMessageBox, QMenu, QFont,
QAction, QApplication, QWidget,
QKeySequence, QMainWindow, QSplitter,
QHBoxLayout)
...
With a promisingly-named EditorWidget
class EditorWidget(QSplitter):
def __init__(self, parent, plugin, menu_actions, show_fullpath,
fullpath_sorting, show_all_files, show_comments):
...
...
This most definitely contains the code being edited. The only question is where it gets created and how we can use it.
It seems to be constructed in EditorMainWindow
:
class EditorMainWindow(QMainWindow):
def __init__(self, plugin, menu_actions, toolbar_list, menu_list,
show_fullpath, fullpath_sorting, show_all_files,
show_comments):
QMainWindow.__init__(self)
self.setAttribute(Qt.WA_DeleteOnClose)
self.window_size = None
self.editorwidget = EditorWidget(self, plugin, menu_actions,
show_fullpath, fullpath_sorting,
show_all_files, show_comments)
Which is itself constructed in the body of Editor
, in plugins/Editor.py
:
class Editor(SpyderPluginWidget):
"""
Multi-file Editor widget
"""
CONF_SECTION = 'editor'
CONFIGWIDGET_CLASS = EditorConfigPage
TEMPFILE_PATH = get_conf_path('temp.py')
TEMPLATE_PATH = get_conf_path('template.py')
DISABLE_ACTIONS_WHEN_HIDDEN = False # SpyderPluginWidget class attribute
# Signals
...
def create_new_window(self):
oe_options = self.outlineexplorer.get_options()
fullpath_sorting=self.get_option('fullpath_sorting', True),
window = EditorMainWindow(self, self.stack_menu_actions,
self.toolbar_list, self.menu_list,
show_fullpath=oe_options['show_fullpath'],
fullpath_sorting=fullpath_sorting,
show_all_files=oe_options['show_all_files'],
show_comments=oe_options['show_comments'])
window.resize(self.size())
window.show()
self.register_editorwindow(window)
window.destroyed.connect(lambda: self.unregister_editorwindow(window))
return window
Jumping around across files is much easier with a smart editor like PyCharm, but even without it searching for the name EditorMainWindow
across the entire project using grep
or ack
or ag
should find you what you want.
And Editor
, itself, is found in spyder.py
as part of the MainWindow
's setup
method:
...
# Editor plugin
self.set_splash(_("Loading editor..."))
from spyderlib.plugins.editor import Editor
self.editor = Editor(self)
self.editor.register_plugin()
# Populating file menu entries
quit_action = create_action(self, _("&Quit"),
icon=ima.icon('exit'),
tip=_("Quit"),
triggered=self.console.quit)
self.register_shortcut(quit_action, "_", "Quit")
restart_action = create_action(self, _("&Restart"),
icon=ima.icon('restart'),
tip=_("Restart"),
triggered=self.restart)
...
Good. Now we know how to get from
MainWindow
to Editor
Editor
to EditorMainWindow
, seemingly through EditorStack
somehowEditorMainWindow
to EditorWidget
EditorWidget
to... where?It turns out that this path may be a bit of a red herring, since there is another class under widgets/sourcecode/codeeditor.py
called CodeEditor
that seems like it really does implement text-editor capabilities.
The CodeEditor
class has a set_text
method that seems to do exactly what we want:
def set_text(self, text):
"""Set the text of the editor"""
self.setPlainText(text)
self.set_eol_chars(text)
#if self.supported_language:
#self.highlighter.rehighlight()
And it's superclass TextEditBaseWidget
has a toPlainText
method that also looks promising for getting the current text to modify:
def toPlainText(self):
"""
Reimplement Qt method
Fix PyQt4 bug on Windows and Python 3
"""
# Fix what appears to be a PyQt4 bug when getting file
# contents under Windows and PY3. This bug leads to
# corruptions when saving files with certain combinations
# of unicode chars on them (like the one attached on
# Issue 1546)
if os.name == 'nt' and PY3:
text = self.get_text('sof', 'eof')
return text.replace('\u2028', '\n').replace('\u2029', '\n')\
.replace('\u0085', '\n')
else:
return super(TextEditBaseWidget, self).toPlainText()
I have no idea what the doc-string is saying, or what all those weird unicode .replace
calls are doing, or what the super
version of toPlainText
is doing. On the other hand if it can call .replace
like that it's probably returning a Python str
, which is what I want, and just from the name I'd guess it probably does what I want.
How does that relate to the editors we've already seen? It appears to be created in EditorStack.create_new_editor
:
def create_new_editor(self, fname, enc, txt, set_current, new=False,
cloned_from=None):
"""
Create a new editor instance
Returns finfo object (instead of editor as in previous releases)
"""
editor = codeeditor.CodeEditor(self)
...
Which is called in EditorStack.new
, which is itself called in Editor.new
. I'm not sure where these things finally get set as attributes I can use, but browsing around these few editor-related files I notice there's a get_current_editor
method on EditorStack
that does exactly what I want:
def get_current_editor(self):
editorstack = self.get_current_editorstack()
if editorstack is not None:
return editorstack.get_current_editor()
I don't know what that implementation does, or where all those other methods are defined, but from the name it very-much-seems that it does exactly what I want and gives me the editor that the IDE is currently focused on. Without looking at the code it still might not - it could do anything! - but if it doesn't give me the currently focused window then whoever wrote that code and gave it this name would have to be a real jerk. Most programmers are usually nice people, so it probably does what I want.
Now we know that we can self.main.editor.get_current_editor()
inside misc_action
to get the current editor; but which editor are we getting? From the name we don't really know, and possibilities include:
spyderlib.plugins.editor.Editor
spyderlib.widgets.editor.EditorMainWindow
spyderlib.widgets.editor.EditorStack
spyderlib.widgets.editor.EditorWidget
spyderlib.widgets.sourcecode.codeeditor.CodeEditor
They all have the word "Editor" in them, and all are plausible return types from the get_current_editor
method! How do we know which one we're getting, given that so far we haven't even managed to print a single thing from our misc_action
method, even though we know it's running?
If this was Java or some other statically-typed language we'd know immediately what a method returns, but...
So we're left guessing in the dark.
It turns out that even though you can't print things or write things to files, there are other ways to get debugging output out of the misc_action
method in order for you to figure out what is going on! One example is using the QMessageBox.warning
method, that's in fact used in the method right below misc_action
, start_ipykernel
:
def start_ipykernel(self, client, wdir=None, give_focus=True):
"""Start new IPython kernel"""
if not self.get_option('monitor/enabled'):
QMessageBox.warning(self, _('Open an IPython console'),
_("The console monitor was disabled: the IPython kernel will "
"be started as expected, but an IPython console will have "
"to be connected manually to the kernel."), QMessageBox.Ok)
if not wdir:
wdir = getcwd()
self.main.ipyconsole.visibility_changed(True)
self.start(fname=None, wdir=to_text_string(wdir), args='',
interact=True, debug=False, python=True, ipykernel=True,
ipyclient=client, give_ipyclient_focus=give_focus)
We can make use of this in our misc_action
method to help figure out what on earth is going on:
$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 4041ac3..1585abf 100644
--- a/spyderlib/plugins/externalconsole.py
+++ b/spyderlib/plugins/externalconsole.py
@@ -1192,6 +1192,9 @@ class ExternalConsole(SpyderPluginWidget):
"""Open terminal"""
if not wdir:
wdir = getcwd()
+
+ QMessageBox.warning(self, _('Open an IPython console'),
+ _(str(type(self.main.editor.get_current_editor()))), QMessageBox.Ok)
self.start(fname=None, wdir=to_text_string(wdir), args='',
interact=True, debug=False, python=False)
This should print out the type of the thing returned by get_current_editor
when we select the action, which should hopefully tell us which of the 5 alternatives it is an what methods/properties we can call on it to do what we want.
And now clicking on Do something
in the dropdown, we get results!
And from this, we can see that of all the possibilities, we know that get_current_editor
returns a spyderlib.widgets.sourcecode.codeeditor.CodeEditor
object, and none of the others. Or, since it's Python, we know that this time it returned a CodeEditor
object, and other times it may return different things. On the other hand, the original programmer would have had to be a real jerk to make it return different types at different times, so we can for now assume he was a nice person and the return type won't change underneath us.
That means we can try calling set_text
on it:
$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 4041ac3..4dfb85b 100644
--- a/spyderlib/plugins/externalconsole.py
+++ b/spyderlib/plugins/externalconsole.py
@@ -1192,6 +1192,7 @@ class ExternalConsole(SpyderPluginWidget):
"""Open terminal"""
if not wdir:
wdir = getcwd()
+ self.main.editor.get_current_editor().set_text("fooooo")
self.start(fname=None, wdir=to_text_string(wdir), args='',
interact=True, debug=False, python=False)
Which gives results:
And then trying toPlainText
, together with the spyderlib.plugins.editor.Editor
's useful get_current_filename()
method that I noticed earlier when digging through the 5 similar Editor
-like classes:
$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 4041ac3..97cac71 100644
--- a/spyderlib/plugins/externalconsole.py
+++ b/spyderlib/plugins/externalconsole.py
@@ -1192,8 +1192,10 @@ class ExternalConsole(SpyderPluginWidget):
"""Open terminal"""
if not wdir:
wdir = getcwd()
- self.start(fname=None, wdir=to_text_string(wdir), args='',
- interact=True, debug=False, python=False)
+ self.main.editor.get_current_editor().set_text(
+ ("print('Running %s')\n" % self.main.editor.get_current_filename()) +
+ self.main.editor.get_current_editor().toPlainText()
+ )
def start_ipykernel(self, client, wdir=None, give_focus=True):
"""Start new IPython kernel"""
And, 5 hours after we initially started trying to install things, it finally works!
Here's the final diff from branching off the github master
branch at time of writing, commit df9577cfc279d2b6f2c07c7ba4e8e7aebfdd1835
:
$ git --no-pager diff
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 4041ac3..97cac71 100644
--- a/spyderlib/plugins/externalconsole.py
+++ b/spyderlib/plugins/externalconsole.py
@@ -1192,8 +1192,10 @@ class ExternalConsole(SpyderPluginWidget):
"""Open terminal"""
if not wdir:
wdir = getcwd()
- self.start(fname=None, wdir=to_text_string(wdir), args='',
- interact=True, debug=False, python=False)
+ self.main.editor.get_current_editor().set_text(
+ ("print('Running %s')\n" % self.main.editor.get_current_filename()) +
+ self.main.editor.get_current_editor().toPlainText()
+ )
def start_ipykernel(self, client, wdir=None, give_focus=True):
"""Start new IPython kernel"""
$ git --no-pager diff df9577cfc279d2b6f2c07c7ba4e8e7aebfdd1835
diff --git a/spyderlib/app/spyder.py b/spyderlib/app/spyder.py
index 6d9763f..4ed17a2 100644
--- a/spyderlib/app/spyder.py
+++ b/spyderlib/app/spyder.py
@@ -408,6 +408,9 @@ class MainWindow(QMainWindow):
self.toolbars_menu = None
self.help_menu = None
self.help_menu_actions = []
+ self.misc_menu = None
+ self.misc_menu_actions = []
+
# Status bar widgets
self.mem_status = None
@@ -663,6 +666,9 @@ class MainWindow(QMainWindow):
# Help menu
self.help_menu = self.menuBar().addMenu(_("&Help"))
+ # Misc menu
+ self.misc_menu = self.menuBar().addMenu(_("&Misc"))
+
# Status bar
status = self.statusBar()
status.setObjectName("StatusBar")
@@ -1181,6 +1187,8 @@ class MainWindow(QMainWindow):
self.external_tools_menu_actions)
add_actions(self.help_menu, self.help_menu_actions)
+ add_actions(self.misc_menu, self.misc_menu_actions)
+
add_actions(self.main_toolbar, self.main_toolbar_actions)
add_actions(self.file_toolbar, self.file_toolbar_actions)
add_actions(self.edit_toolbar, self.edit_toolbar_actions)
diff --git a/spyderlib/plugins/externalconsole.py b/spyderlib/plugins/externalconsole.py
index 9b8ba50..97cac71 100644
--- a/spyderlib/plugins/externalconsole.py
+++ b/spyderlib/plugins/externalconsole.py
@@ -1021,6 +1021,13 @@ class ExternalConsole(SpyderPluginWidget):
_("Open a &Python console"), None,
ima.icon('python'),
triggered=self.open_interpreter)
+ misc_action = create_action(self,
+ _("&Do Something"), None,
+ ima.icon('python'),
+ triggered=self.misc_action)
+
+ self.main.misc_menu_actions = [misc_action]
+
if os.name == 'nt':
text = _("Open &command prompt")
tip = _("Open a Windows command prompt")
@@ -1041,7 +1048,7 @@ class ExternalConsole(SpyderPluginWidget):
self.main.consoles_menu_actions += consoles_menu_actions
self.main.tools_menu_actions += tools_menu_actions
- return self.menu_actions+consoles_menu_actions+tools_menu_actions
+ return self.menu_actions+consoles_menu_actions+tools_menu_actions+[misc_action ]
def register_plugin(self):
"""Register plugin in Spyder's main window"""
@@ -1180,6 +1187,16 @@ class ExternalConsole(SpyderPluginWidget):
self.start(fname=None, wdir=to_text_string(wdir), args='',
interact=True, debug=False, python=True)
+ @Slot(str)
+ def misc_action(self, wdir=None):
+ """Open terminal"""
+ if not wdir:
+ wdir = getcwd()
+ self.main.editor.get_current_editor().set_text(
+ ("print('Running %s')\n" % self.main.editor.get_current_filename()) +
+ self.main.editor.get_current_editor().toPlainText()
+ )
+
def start_ipykernel(self, client, wdir=None, give_focus=True):
"""Start new IPython kernel"""
if not self.get_option('monitor/enabled'):
We're not nearly "done", from the point of view of making a serious contribution. While we've demonstrated a patch that works, with changes we could do, it's another thing entirely to understand the underlying codebase enough to know what changes we should do and how to do them. For example, dumping the misc__action
command in externalconsole.py
works, but it definitely isn't the right thing to do, and would never make it past code review!
Nonetheless, this would get us to the start of the code-review process in a professional setting. We have gotten enough understanding of the overall architecture of the codebase to have intelligent discussions about how our changes would fit into the wider codebase, as well as working (if rough) patch we can use as a base for polish and tidying-up on. Not a bad result for 5 hours of investment!
We've made it pretty far in wrestling with, understanding, and making progress against a large, existing codebase from a starting point of total ignorance. Here are some interesting lessons learned:
Installation is a pain and that's ok. It took me two hours to get Spyder working on my laptop. That's enough time to get frustrated, but not a lot of time in the grand scheme of things. Have the professionalism to willingly sacrifice quantities of time without frustration or unhappiness. Your time and effort is a resource like any other, and directing the fire-hose of your effort into conquering a borked setup process is just another task of many tasks you will overcome in your career.
You do not need to understand everything. In fact, you do not need to understand most things, at least initially. There are too many things for you to understand all at once. You will be working with partial understanding of the codebase and that is totally ok. Later on you can take another pass, after you get things "working", to really-truly understand what is going on.
Your methods for understanding are messy and ad-hoc. You are not going to be writing mathematical proofs about a new codebase. Instead, you are searching for keywords, browsing the filesystem, jumping around from one clue to the next. You should be working in theories, clues, hints, and suspicions during this period of unfamiliarity: we ain't got no time for a systematic search for what you want.
Unrelated experience is important. When you don't understand what on earth is going on, you rely on your experience with similar problems in the past in order to make snap decisions or judgements. For example, experience says the text at the top of the window is called a title. Experience says I can install odd things things using brew install
. Experience tells me that somewhere in the codebase, there will be methods to get_text
and set_text
the text editor's contents, even though I've never seen this codebase before in my life. Having dealt with similar problems elsewhere, it makes the ad-hoc search and guesses much more accurate.
The codebase you will be working with is messy and ad-hoc. Why are there 5-6 different classes all of which are called some variant of Editor
, that are all intricately related to each other in subtle ways? Why is the functionality commonly related to "editors" like text content, cursors and selection split throughout all of them, and mixed with other distinctly non-editor-related things? Could some of them have been named better? Have better docstrings? Definitely. Will they? Probably not in any real-world codebase I've seen. That's life.
Verify everything. "My code is correct" needs verification. "My code is running" needs verification. "My code isn't crashing and having its errors silently swallowed" needs verification. "The changes I'm making to these files are making it into the running application and aren't being ignored" needs verification. "When I print something, it turns up in the console" needs verification, and in this case was false! Take baby steps and don't make massive changes without verifying these basics. You are unfamiliar with the codebase: there will be countless things that don't behave as you'd expect.
You don't learn this skill in school, but you can learn it. Diving into unfamiliar code is not a talent: it is a skill, and can be trained like any other. Designing algorithms and systems, writing working code to get things done, diving into other people's code, wrestling with baroque and obnoxious dependencies, these are just a few facets of being a professional software engineer.
And that's how you dive into other people's code you've never seen before, and make non-trivial changes to it while working in a state of half-ignorance for a few hours. Feel free to post your own stories, tricks or techniques in the comments below!
About the Author: Haoyi is a software engineer, and the author of many open-source Scala tools such as the Ammonite REPL and the Mill Build Tool. If you enjoyed the contents on this blog, you may also enjoy Haoyi's book Hands-on Scala Programming