编写脚本:更新到 Python 3

教程
作者:Rainer Erich Scheichelbauer
en zh

5 十月 2020 发布日期: 26 十一月 2019

Glyphs 3 来了,并且 Python 2 逐渐过时。本教程介绍让你的脚本和插件代码在 Glyphs 2 和 3,以及 Python 2 和 3 中都可工作。这样,你的用户就可以平滑过渡了。

好,那么本文将介绍如何升级你的代码,使其在两种环境下都可以工作。没错,这是可以做到的。而且很酷的是,它比你想象的要轻松。我们来分两步走:首先将所有代码转移到 Python 3,然后下一步,使其适应新的 API。

所以,我们来修改代码。读下去吧。

快速指南

First, we will stay in Glyphs 2, but upgrade our code to Python 3 and at the same time, keep it compatible with Python 2. To achieve this, you need to do these couple of things in all of your scripts:

  1. Make sure the heads of your scripts contain these __future__ imports:
#MenuTitle: SCRIPTNAME
# -*- coding: utf-8 -*-
from __future__ import division, print_function, unicode_literals
  1. Change all your print x statements into print(x) functions. (The futurize script can help you with that, see further below.)

  2. Explicitly import all NS objects from Foundation (basic stuff) or AppKit (anything UI-related). When in doubt, import from AppKit, because AppKit includes Foundation. E.g., from Foundation import NSPoint, etc.

Now verify if your scripts still work in Glyphs 2.

详解

获取 Python 3

Check to see if you have Python 3 installed already. In Terminal.app, type:

python3 --version

… and press Return. You should get something like this as a result:

Python 3.7.5

If that is what you have, you’re good. Please skip to the next chapter.

If, however, you get a command not found error in return, then you need to install Python 3. Te best way to do this is to make sure you have a good internet connection and type this in Terminal.app:

brew install python3

Should you get permission errors, try again with a prepended sudo:

sudo brew install python3

If you have to resort to sudo, you will be asked to type your Mac password. Don't be alarmed if you do not see password bullets, that is just the way Terminal handles passwords. Just type your password ‘blindly’ and press the Return key to continue.

If the error you receive is about brew being unknown, you may need to install Homebrew first.

Once this is done, try the python3 --version command again.

Still getting an error? If you know how to handle homebrew, try brew install python3 or sudo brew install python3, and then try again. If you still get an error, please make yourself heard in the forum. We will help you there.

Update 2020-01-21: Tim Ahrens reports difficulties under certain conditions: When you run python3 --version you will receive a command not found error. However, when you do a brew install python3, it will tell you that no formulae were changed, perhaps adding that Python 3 is already installed but not linked. It will suggest to use brew link python. If that works for you, fine. You may however still run into trouble, especially symlink error messages. In that case, try brew link --overwrite python to force the symlink, or delete the offending directory and try brew link python again.

Again, verify with python3 --version if everything is done now.

批量替换

All modern code editors (Sublime Text, TextMate, Atom, but also BBEdit) support finding and replacing across multiple files, or iterating through a folder of .py files. Take a closer look at the options in you Find dialog of your preferred editor. It’s easy.

print()

This, by far, is going to be the biggest change. In fact, for the very most scripts, it will be the only significant change in the code.

In Python 2, print was a statement. That means that you would write the word print followed by a space, followed by whatever expression you wanted to be evaluated and printed. In Python 3, print() is a function. That means it gets those parentheses at the end, and whatever expression you want it to evaluate and print, must be an argument inside those parentheses. In other words, what used to be print "hello" has now become print("hello"). Apart from that, it works pretty much the same way it used to do. That includes formatting strings like this: print("Error in glyph %s."%glyphname), or chaining arguments like this: print("a","b","c").

You can easily make your code compatible with both Python 2 and 3 by simply adding this import at the top, even before the #MenuTitle line:

from __future__ import print_function

… and then converting every instance of print "..." into print("...")

One special case in Python 2 was the print statement with a comma at the end of the line. That way, the inherent newline at the end of each statement execution was suppressed. In order to replicate this in Python 3, you would have to write:

print(letter, end='')

But that’s about it. So, add that import at the very top, add those parentheses to your print statements, and you’re all set.

字符串

This is probably a non-issue. But there may be an edge case where this may make a difference. In Python 2, strings were 7-bit ASCII by default. In Python 3, they are UTF-8 by default. If you want to start coding this way, and still stay compatible with Python 2, you can add this import at the top of the .py file:

from __future__ import unicode_literals

And all your strings will be UTF-8 strings. But do not worry too much about this, because the u"..." construction still is accepted in Python 3. However, if you like, you can start cleaning out these superfluous u’s now.

One thing you may still need to clean up are str() calls because they are more likely to throw errors at you. Get rid of them wherever possible and replace them with formatting strings. So, instead of str(myNum), better use a construction like "%i"%myNum.

Or you don’t care and leave the strings as they are, and only add those imports if you get ‘Non-ASCII’ errors.

除法

In Python 2, if all involved numbers were int, the whole calculation would be int only. Typically this would make for surprising results with the division, where 3/2 would yield 1 and not 1.5. In other words, it would default to a floor division.

This is different in Python 3. 3/2 yields 1.5. If, for whatever reason, you still need a floor division, you can still use the double slash: 3//2, and get 1 as a result.

I recommend you switch to the Python 3 behavior and always import the new division operator:

from __future__ import division

And feel free to get rid of any float() conversions you used to throw at your integers in order to prevent the floor division in Python 2.

例外

The try statement lets you catch errors, and if one actually happens, it will execute whatever you write after the following except. However, if you used this construction in Python 2:

except Exception, e:

… you will now have to turn it into this:

except Exception as e:

Note the word as instead of the comma.

Futurize 脚本

There is a script for updating your print and except statements! You should already have it, but if not, you can pip install future (or sudo pip install future if you receive an error) in your Terminal, and then try this line inside a folder with .py files:

futurize -1 -w *.py

And it will make all the changes above inside all .py files, and save them back into their files. Ta-daa!

Hint 1: The futurize script also creates .bak copies of the .py files. You may want to keep them around for a short while, just in case. But you definitely do not want those backup files in your git repository. So make sure you include *.bak in your .gitignore.

Hint 2: The script may also insert the from builtins import str line, which may cause problems if the user does not have future installed from pip. So you may want to (batch) remove that line again. See above.

Glyphs 3 导入

One major change in Glyphs 3 is the fact that Foundation and AppKit are not imported automatically at the first script run anymore. Why? Because it significantly slows down the first script the user runs in a session.

It is much better to import only the stuff you need, e.g., like this:

from AppKit import NSPoint, NSRect

That way only the submodules are loaded that the script needs, and the first script runs much quicker. And the user is happy. And that is what we all want, isn't it.

So, what you have to do is go through your code and look for any reference to objects that start with NS. That include some basic things like NSPoint, NSRect, and the like. But also more advanced stuff like NSPasteboard and NSStringPboardType for clipboard handling.

The good news: I updated the Python for Glyphs snippets accordingly already. Each snippet includes the imports it needs, and where applicable, even the __future__ imports for Python 2/3 compatibility.

Hint 1: In Python 3 it is pretty safe (and fast) to use from Foundation import *. However, this will leave behind the people who use Glyphs 3 with Python 2. So better always import exactly what you need. That is considered better practice anyway.

Hint 2: We are still considering importing Foundation and AppKit automatically, because of the better importing performance in Python 3, so you may get away without updating after all when Glyphs 3 ships one day. But don’t rely on that.

ObjectiveC 修饰

Have you written plug-ins for Glyphs? You may also need to update their code. But don't worry, it is not much you have to do.

If you (a) have added self-baked method of your own to the plug-in class, and (b) that method’s name does not adhere to the PyObjC-style underscore and camelcase structure, like doStuffWithArg_andWithArg_(self, A, B), then you need to prepend a python-method decorator. This looks like this:

@objc.python_method
def updateView(self, view=None):
    if view:
        view.update()
    return True

If you do not add @objc.python_method, a selector object will be created for the method. That is not necessarily a bad thing, but you would only want that if you want to access the method from outside the plug-in. And you would have to make it Objective-C compatible by adding underscores for the arguments and using camelcase. Otherwise you will get a lot of errors thrown at you. Or, in the words of the PyObjC documentation:

This is used to add “normal” python methods to a class that’s inheriting from a Cocoa class and makes it possible to use normal Python idioms in the part of the class that does not have to interact with the Objective-C world.

If you already have a decorator in front of the method (rare case), then add the @objc.python_method even before that other decorator, e.g. @property:

@objc.python_method
@property
def updateView(self, view=None):
    if view:
        view.update()
    return True

But again, this is a pretty unlikely scenario within the realm of Glyphs plug-ins, so chances are this does not affect you.

更新 SDK

If you have a plug-in in the Plugin Manager, you may want to check out the updated SDK. It is now Python 3 compatible. And the templates have an updated ‘MacOS’ folder:

Updated 2020-04-18: In other words, if your plug-in is not exactly recent, make sure the plugin binary is updated to the current version. Update your Info.plist with the ones in the SDK, and throw out keys that are not necessary anymore.

更新插件的分步指南

  1. Remove these files from your plug-in if they are still there (hint: you can do this with the context menu inside the file navigators of TextMate or SublimeText):
    • /Content/PkgInfo
    • /Content/MacOS/main.py
    • /Content/MacOS/python
    • /Content/Resources/__boot__.py
    • /Content/Resources/__error__.sh
    • /Content/Resources/site.py
  2. Replace the binary /Content/MacOS/plugin with the corresponding file from the current SDK template. Again, drag&drop with the Opt key works in and between file navigators as well.
  3. Update your /Content/Info.plist to the structure of the current templates in the SDK. Best to keep them side by side so you see differences right away:
    • Make sure your header has:
      • <?xml version="1.0" encoding="UTF-8"?>
      • <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    • Remove CFBundleDisplayName and CFBundlePackageType if they are there. Do the same for NSMainNibFile and CFBundleSignature if you still have those hanging around.
    • The key PyMainFileNames should have an <array><string>plugin.py</string></array> and nothing else. Old versions pointed to the main.py file.
    • Delete everything after the PyMainFileNames array. Only the closing tags </dict> and </plist> should follow.
    • Consider updating CFBundleVersion, CFBundleShortVersionString, productReleaseNotes.
    • Compare your <key></key> elements with the ones in the SDK template. Consider throwing out keys that are not in the template. You only need these: CFBundleDevelopmentRegion, CFBundleExecutable, CFBundleIdentifier, CFBundleInfoDictionaryVersion, CFBundleName, CFBundleShortVersionString, CFBundleVersion, UpdateFeedURL, productPageURL, productReleaseNotes, NSHumanReadableCopyright, NSPrincipalClass, PyMainFileNames.
  4. Update your /Resources/plugin.py:
    • Right after the # encoding: utf-8 line, add the future imports on the second line: from __future__ import division, print_function, unicode_literals. Hint: the fut⇥ snippet from the Python for Glyphs repository helps.
    • After that, make sure you have these imports:
      • import objc
      • from GlyphsApp import *
      • from GlyphsApp.plugins import *
      • …and whatever other imports you need for your code, I often see math there.
      • Get rid of unnecessary legacy imports. In old templates, we used to have sys and os imports. We do not need those anymore. If you are not sure, comment them out and see if the plug-in works without them.
      • If you only need one or two functions from a library, consider the from ... import ... style of importing, e.g., from math import tan, hypot and change any occurrences of math.tan() or math.hypot() to simply tan() and hypot(), etc.
    • Decorate all functions (inside and outside your class definition) that do not have a PyObjC-compatible name (=starting with lowercase, camel-cased, underscore for each argument) with @objc.python_method (this is called the decorator, Google pyobjc decorator it if you are unfamiliar with the concept). I.e., simply put the decorator on the line before the def statement, with the same indent as the def statement. Hint: the dec⇥ snippet from the Python for Glyphs repository helps.
    • Exception to the above: Functions called by a menu item or by a callback must be PyObjC-compatible, i.e., starting with lowercase, camel-cased, and contain an underscore for each argument, and they must have no decorator.
    • Turn all print statements into print() functions.
    • Turn all layer.paths.append() and layer.components.append() into layer.shapes.append(), consider a try/except statement. Hint: the g23⇥ snippet from the Python for Glyphs repository helps.
    • Turn if customParameters.has_key(x): into if x in customParameters:
    • That should cover 99% of the changes you will make. For more details on these and other Python 3 changes, read on below.
  5. Test your plug-in: Force restart Glyphs 3, and put the bundle name of your plug-in into the search field of Console.app, and see what happens. !PrincipalClass is the most frequent error. If it occurs:
    • Make sure NSPrincipalClass in your Info.plist matches the class name in plugin.py.
    • Make sure all necessary decorators are in place.
    • Make sure all functions called by menu items and callbacks are undecorated and have a PyObjC-compatible name.
    • Make sure you really replaced the /Contents/MacOS/plugin binary with the current one.

Good luck. Report back in the forum if you still have trouble, best in the Upgrading Plug-ins thread.

主要 API 变更

Then, there is another big thing coming at you. Due to the changes under the hood, the scripting API will change as well. For some part, it will stay the same, but in some areas, the differences are going to be huge. Because it is still under development, I can only be vague at the moment. For now, I’ll leave you with these two change warnings:

  • Font Info: The way it is going to be organized in Glyphs 3 is going to be very different from Glyphs 2, not in the least to accommodate changes for facilitating variable font production.
  • Shapes on the layer: In order to access anything on the layer, including components and paths, you will find yourself looping through GSLayer.shapes in the future.

Shapes: Paths and Components

Anything visible on a layer is now collected in GSLayer.shapes. Yet, GSLayer.paths and GSLayer.components are still around. But they are now merely wrapped shortcuts, and equivalent to [s for s in GSLayer.shapes if type(s)==GSPath] (and GSComponent, respectively… you get the idea). That means that this still works:

for path in Layer.paths:
    print(path)
    for node in path.nodes:
        print(node)

But there is one thing you cannot do anymore: access paths or components through their index number. I.e., Layer.paths[2] will raise an error in Glyphs 3. That is because only the shapes are enumerated now. This affects how you would recursively delete paths with the del statement. Here is a sample snippet that shows how you would do it now:

# Delete all paths with less than 4 nodes:
for i in range(len(Layer.shapes)-1,-1,-1):
    shape = Layer.shapes[i]
    if type(shape) == GSPath: # NEW: check for paths
        if len(shape.nodes) < 4:
            del Layer.shapes[i]

Just in case you have never done that: You see the range(...-1, -1, -1) construction? This gives us all the index numbers in reverse order. That way, when we actually delete an item, the index number of the following shape in the loop does not change.

The other thing you (at least currently) cannot do anymore, is to append a new GSComponent object to GSLayer.components, or a GSPath object to GSLayer.paths. You append to GSLayer.shapes instead, like this:

newComp = GSComponent("A")
try:
    # GLYPHS 3:
    Layer.shapes.append(newComp)
except:
    # GLYPHS 2:
    Layer.paths.append(newComp)

The shapes restructuring means that you can do things like this now: iterate over shapes, and sort them out by type with appropriate if statements. That is, check if the shape is a GSPath or a GSComponent, and proceed accordingly. E.g., this is how you would loop over all shapes on a layer:

for i, shape in enumerate(Layer.shapes):
    print(i, shape)
    if type(shape) == GSPath:
        for node in shape.nodes:
            print(node)
    elif type(shape) == GSComponent:
        print(shape.position)

Similarly, if you want to delete all components but at the same time keep all paths that may be on a layer, you may have been used to do something like Layer.components=None. But that won’t work anymore. So you will have to iterate backwards through the enumerated shapes and del the ones that check out as GSComponent:

for i in range(len(Layer.shapes)-1, -1, -1):
    if type(Layer.shapes[i]) == GSComponent:
        del Layer.shapes[i]

Likewise if you want to delete all GSPath objects and keep GSComponent objects. Just test for GSPath rather than GSComponent, of course.

This will be one of the biggest changes in your scripts and plug-ins.

GSGuide 参考线

Guides are called guides now. In other words, there’s no more ‘Line’ at the end. That means: GSGuide is the new class name, and you would access GSLayer.guides and GSFontMaster.guides. Other than that, everything is the same.

同时支持 Glyphs 2 和 3

Now, in your code transition to Glyphs 3, you will probably want to also support app version 2. And luckily, there is a way to do it without having to resort to maintaining two repositories. It is easy: You wrap your code in a try...except construction, put the Glyphs 3 code in the try: block, and the Glyphs 2 code in the except: part. Here is one of the examples from above:

try:
    # GLYPHS 3:
    for shape in Layer.shapes:
        if type(shape) == GSPath:
            print(shape)
            for node in shape.nodes:
                print(node)
except:
    # GLYPHS 2:
    for path in Layer.paths:
        print(path)
        for node in path.nodes:
            print(node)

The Glyphs 3 code will raise an error in Glyphs 2, but the except clause catches the error and the fallback code after try is executed. It is that easy, works like a charm in both version two and three.

资源

Further reading? Here you go: