diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..77ac5b3 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,32 @@ +name: Publish to PyPI +on: + workflow_dispatch: + release: + types: [published] + +jobs: + publish: + name: Publish to PyPI + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Run Tests + run: | + pip3 install -U pip wheel twine + python3 setup.py test + + - name: Build sdist + run: | + python3 setup.py sdist + + - name: Upload to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + TWINE_NON_INTERACTIVE: 1 + run: | + twine check dist/* + twine upload dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fc27eb7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: New release +on: + push: + tags: + - "v*" + +jobs: + app: + name: Build App + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Keychain + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + AC_TEAM: ${{ secrets.AC_TEAM }} + AC_ID: ${{ secrets.AC_ID }} + run: | + # import code signing cert + CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 + echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode --output $CERTIFICATE_PATH + + # create temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + # import certificate to keychain + security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + + # collect notarization credentials + xcrun notarytool store-credentials "AC_NOTARY" --apple-id "$AC_ID" --team-id "$AC_TEAM" --password "${{ secrets.AC_PASSWORD }}" + + - name: Run Tests + run: | + python3 setup.py test + + - name: Build & Notarize App + run: | + python3 setup.py dist + + - name: Create Draft Release + uses: softprops/action-gh-release@v1 + with: + draft: true + name: PlotDevice ${{ github.ref_name }} + body_path: CHANGES.md + files: | + dist/*.zip + + sdist: + name: Post to test.pypi.org + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Run Tests + run: | + python3 setup.py test + + - name: Build Source Distribution + run: | + python3 setup.py sdist + + - name: Upload to test.pypi.org + env: + TWINE_USERNAME: samizdat + TWINE_PASSWORD: ${{ secrets.PYPI_TEST_PASSWORD }} + TWINE_REPOSITORY: https://test.pypi.org/legacy/ + TWINE_NON_INTERACTIVE: 1 + run: | + pip3 install twine + twine check dist/* + twine upload -r testpypi dist/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e051121 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Run tests +on: + workflow_dispatch: + push: + pull_request: + branches: + - main + +jobs: + run-tests: + runs-on: macos-latest + + steps: + - name: Checkout repository + id: repo + uses: actions/checkout@v3 + + - name: Add missing setuptools + run: brew install python-setuptools + + - name: Run tests + run: python3 setup.py test diff --git a/.gitignore b/.gitignore index e505b4f..6da63f0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,9 +12,10 @@ Manifest.in deps/local /*.so -# app dependencies +# external dependencies deps/frameworks/*.framework deps/frameworks/relocatable-python +deps/extensions/svg/SwiftDraw* # cds garbage related/ diff --git a/CHANGES.md b/CHANGES.md index 5f56748..4cd9fdb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,18 +2,21 @@ PlotDevice 1.0.0 ----------------- ##### New Features +* Runs natively on Intel and Apple Silicon and supports retina displays * Python 3 support (including a bundled 3.10 installation in the app) * images can now be exported in HEIC format and videos support H.265 (HEVC) -* image exports have a configurable 'zoom' to create 2x/3x/etc 'retina' images +* SVG files can be drawn to the canvas using the `image()` command (thanks to the magical [SwiftDraw](https://github.com/swhitty/SwiftDraw) library) +* image exports have a configurable `zoom` to create 2x/3x/etc ‘retina’ images * revamped `var()` command for creating GUIs to modify values via sliders, buttons, toggles, etc. -* updated text editor with multiple tabs, new themes, and additional key-binding modes for sublime and vs code users +* updated text editor with multiple tabs, new themes, and additional key-binding modes emulating Sublime Text and VS Code * the module's command line interface is now accessible through `python3 -m plotdevice` -* user-configurable document autosaving +* the command line tool has a new `--install` option to download [PyPI](https://pypi.org) packages for use within the app +* document autosaving is now user-configurable ##### Bugfixes * exported images generated on retina machines now have the proper dimensions * hex colors can now use lowercase letters -* automatic variables like WIDTH & HEIGHT correctly support the `/` operator +* automatic variables like `WIDTH` & `HEIGHT` correctly support the `/` operator * the Color object's `.blend()` method is working again * the `read()` command can now handle csv files with spaces in their header row names * the `translate()` command now incorporates non-pixel grid units set via the `size()` command @@ -27,6 +30,10 @@ PlotDevice 1.0.0 * the `text()` command will always treat its first argument as content (even if it's not a string) unless a `str`, `xml`, or `src` keyword argument is provided * the mouse pointer is now visible in full-screen mode (and will auto-hide when inactive) +##### Unfortunate Casualties +* The NodeBox Libraries (`coreimage`, `colors`, and friends) would require quite a bit of attention to get working properly again. + A first pass can be found in the [`plotdevice-libs` repository](https://github.com/plotdevice/plotdevice-libs) but they're not + ready for prime-time. If you're interested in contributing, this would be a terrific place to start! PlotDevice 0.10.0 ----------------- diff --git a/PlotDevice.xcodeproj/project.pbxproj b/PlotDevice.xcodeproj/project.pbxproj index ba4bca4..1464ae4 100644 --- a/PlotDevice.xcodeproj/project.pbxproj +++ b/PlotDevice.xcodeproj/project.pbxproj @@ -607,7 +607,7 @@ INFOPLIST_FILE = app/info.plist; INSTALL_PATH = /Applications; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @executable_path/../Frameworks/Python.framework"; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 10.13; ONLY_ACTIVE_ARCH = NO; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = io.plotdevice.PlotDevice; @@ -625,6 +625,7 @@ CODE_SIGN_IDENTITY = "-"; COMBINE_HIDPI_IMAGES = YES; CONFIGURATION_BUILD_DIR = dist; + COPY_PHASE_STRIP = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_HARDENED_RUNTIME = YES; FRAMEWORK_SEARCH_PATHS = ( @@ -637,7 +638,7 @@ INFOPLIST_FILE = app/info.plist; INSTALL_PATH = /Applications; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @executable_path/../Frameworks/Python.framework"; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 10.13; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = io.plotdevice.PlotDevice; PRODUCT_NAME = PlotDevice; diff --git a/README.md b/README.md index 5269047..d12a29a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ a general purpose graphics library for use in external Python programs. PlotDevice scripts can create images from simple geometric primitives, text, and external vector or bitmap images. Drawing commands provide a thin abstraction -over Mac OS X's Quartz graphics engine, providing high-quality rendering +over macOS's Quartz graphics engine, providing high-quality rendering of 2D imagery and powerful compositing operations. #### Requirements @@ -24,32 +24,40 @@ and those [installed through Homebrew](https://docs.brew.sh/Homebrew-and-Python) Over the years since the last release, progress in both macOS and Python itself led to quite a bit of breakage. Some of the highlights of this maintenance release include: -New Features +###### New Features +- Runs natively on Intel and Apple Silicon and supports retina displays - Python 3 support (including a bundled 3.10 installation in the app) - images can now be exported in HEIC format and videos support H.265 (HEVC) -- image exports have a configurable 'zoom' to create 2x/3x/etc 'retina' images +- SVG files can now be drawn to the canvas using the `image()` command (thanks to the magical [SwiftDraw](https://github.com/swhitty/SwiftDraw) library) +- image exports have a configurable `zoom` to create 2x/3x/etc ‘retina’ images - revamped `var()` command for creating GUIs to modify values via sliders, buttons, toggles, etc. -- updated text editor with multiple tabs, new themes, and additional key-binding modes for sublime and vs code users +- updated text editor with multiple tabs, new themes, and additional key-binding modes emulating Sublime Text and VS Code - the module's command line interface is now accessible through `python3 -m plotdevice` -- user-configurable document autosaving +- the command line tool has a new `--install` option to download [PyPI](https://pypi.org) packages for use within the app +- document autosaving is now user-configurable -Bugfixes +###### Bugfixes - exported images generated on retina machines now have the proper dimensions - hex colors can now use lowercase letters -- automatic variables like WIDTH & HEIGHT correctly support the `/` operator +- automatic variables like `WIDTH` & `HEIGHT` correctly support the `/` operator - the Color object's `.blend()` method is working again - the `read()` command can now handle csv files with spaces in their header row names - the `translate()` command now incorporates non-pixel grid units set via the `size()` command - cmyk exports are working reliably for command line `--export` and via the `export(cmyk=True)` method - arguments defined using the command line tool's `--args` options are now passed to the script's `sys.argv` -Misc. Improvements +###### Misc. Improvements - the command line tool can be exited via ctrl-c in addtion to being Quit from the menu bar - simplified unicode handling (and improved support for normalization of user-provided strings) - building the module now only requires Xcode command line tools—not a full Xcode.app installation - the `text()` command will always treat its first argument as content (even if it's not a string) unless a `str`, `xml`, or `src` keyword argument is provided - the mouse pointer is now visible in full-screen mode (and will auto-hide when inactive) +###### Unfortunate Casualties +- The NodeBox Libraries (`coreimage`, `colors`, and friends) would require quite a bit of attention to get working properly again. + A first pass can be found in the [`plotdevice-libs` repository](https://github.com/plotdevice/plotdevice-libs) but they're not + ready for prime-time. If you're interested in contributing, this would be a terrific place to start! + Installation ------------ @@ -78,7 +86,9 @@ and launch scripts from the command line (or from a ‘shebang’ line at the to script invoking the `plotdevice` tool). To install the module and command line tool use `python3 setup.py install` -Easier still, you can install the module directly from PyPI with a simple `pip3 install plotdevice` +Easier still, you can install the module directly from PyPI with a simple `pip3 install plotdevice`. +It's a good idea to install the `wheel` module first since it greatly speeds up installation of the +PyObjC libraries PlotDevice depends on. #### Alternative Python Interpreters @@ -96,6 +106,11 @@ pip3 install plotdevice python3 -m plotdevice ``` +#### Building from source + +You can also clone the git repository and build PlotDevice as a module or application from scratch. +Consult the [build instructions](https://github.com/plotdevice/plotdevice/discussions/59) for details. + Documentation ------------- @@ -118,7 +133,7 @@ directly via `python3 -m plotdevice` (which accepts all the same command line ar #### Command line usage ``` plotdevice [-h] [-f] [-b] [-q] [--live] [--cmyk] [--virtualenv PATH] [--args [a [b ...]]] - [--export FILE] [--frames N or M-N] [--fps N] [--rate N] [--loop [N]] + [--export FILE] [--frames N or M-N] [--fps N] [--rate N] [--loop [N]] [--install [PACKAGES ...]] file ``` @@ -144,6 +159,11 @@ plotdevice [-h] [-f] [-b] [-q] [--live] [--cmyk] [--virtualenv PATH] [--args [a > `--fps N` frames per second in exported video (default `30`) > `--rate N` video bitrate in megabits per second (default `1`) > `--loop [N]` number of times to loop an exported animated gif (omit `N` to loop forever) +> +> ##### Installing Packages from [PyPI](https://pypi.org): +> `--install [packages ...]` Use `pip install` to download libraries into the **~/Library/Application Support/PlotDevice** directory, making +> them `import`-able in the application and by scripts run from the command line + #### Usage examples @@ -166,6 +186,9 @@ plotdevice script.pv --export output.png --frames 10 # Create a 5 second long H.265 video at 2 megabits/sec plotdevice script.pv --export output.mov --frames 150 --rate 2.0 + +# Install some useful modules +plotdevice --install urllib3 jinja2 numpy ``` @@ -174,7 +197,7 @@ Using external libraries Since PlotDevice scripts are pure Python, the entirety of the [stdlib](https://docs.python.org/3/library/) and [PyPI](https://pypi.python.org/pypi) -are avaliable to you. In addition, a wide array of PlotDevice Libraries have been contributed +are available to you. In addition, a wide array of PlotDevice Libraries have been contributed by the community to solve more visualization-specific problems. #### Installing PlotDevice Libraries @@ -359,18 +382,18 @@ with export('{2}-img.png') as img: Lineage ------- -PlotDevice was derived from [NodeBox](http://nodebox.net/code)'s 1.9.7 release. Its current maintainer is +PlotDevice was derived from [NodeBox](https://nodebox.net/code/index.php/Home)'s 1.9.7 release. Its current maintainer is [Christian Swinehart](mailto:drafting@samizdat.co). NodeBox is a BSD-licensed graphics environment written by [Frederik De Bleser](mailto:frederik@burocrazy.com). The NodeBox manual and example code are by [Tom De Smedt](mailto:tomdesmedt@organisms.be). -NodeBox is a fork of [DrawBot](http://drawbot.com) by [Just van Rossum](mailto:just@letterror.com). +NodeBox is a fork of [DrawBot](https://drawbot.com) by [Just van Rossum](mailto:just@letterror.com). License ------- -PlotDevice is released under the [MIT license](http://opensource.org/licenses/MIT). Use it as you see fit. +PlotDevice is released under the [MIT license](https://opensource.org/licenses/MIT). Use it as you see fit. Contributing ------------ diff --git a/app/Resources/en.lproj/Credits.rtf b/app/Resources/en.lproj/Credits.rtf index b621200..1b65a97 100755 --- a/app/Resources/en.lproj/Credits.rtf +++ b/app/Resources/en.lproj/Credits.rtf @@ -10,7 +10,7 @@ \f0\b\fs26 \cf2 Authors\ \pard\pardeftab720\ri4167\sl360\slmult1 -\b0 \cf0 Christian Swinehart \cf3 |\cf4 {\field{\*\fldinst{HYPERLINK "mailto:drafting@samizdat.cc"}}{\fldrslt \cf5 drafting@samizdat.cc}}\cf0 \ +\b0 \cf0 Christian Swinehart \cf3 |\cf4 {\field{\*\fldinst{HYPERLINK "mailto:drafting@samizdat.co"}}{\fldrslt \cf5 drafting@samizdat.co}}\cf0 \ \pard\pardeftab720\ri4167 \f1\i\fs30 \cf6 API redesign & docs, multithreaded export, enhanced code editor, command line tool, and syntax modernization diff --git a/app/Resources/en.lproj/PlotDeviceDocument.xib b/app/Resources/en.lproj/PlotDeviceDocument.xib index ff51e51..2e5791f 100644 --- a/app/Resources/en.lproj/PlotDeviceDocument.xib +++ b/app/Resources/en.lproj/PlotDeviceDocument.xib @@ -119,6 +119,9 @@ + + + diff --git a/app/plotdevice b/app/plotdevice index 28dc959..9d7d355 100755 --- a/app/plotdevice +++ b/app/plotdevice @@ -1,18 +1,20 @@ #!/bin/sh -export _p_l_o_t_d_e_v_i_c_e_=$(basename ${0}) -python=$( +env=$( self=${0} - while [ -L "${self}" ]; - do - cd "${self%/*}" - self=$(readlink "${self}") - done + if [ -L "${self}" ]; then + self=$(readlink -f "${self}") + fi cd "${self%/*}" - echo "$(pwd -P)/python3" + echo "$(pwd -P)" ) -if [ ! -x $python ]; then - python=python3 +PYTHON="$env/python3" +PIP="$env/pip3" +if [ ! -x $PYTHON ]; then + PYTHON=python3 + PIP=pip3 fi -$python -m plotdevice $@ \ No newline at end of file +export _p_l_o_t_d_e_v_i_c_e_=$(basename ${0}) +export _p_l_o_t_d_e_v_i_c_e___p_i_p_=$PIP +$PYTHON -m plotdevice $@ diff --git a/app/python.xcconfig b/app/python.xcconfig index 3f55206..868ff60 100644 --- a/app/python.xcconfig +++ b/app/python.xcconfig @@ -1,7 +1,7 @@ // Generated by deps/frameworks/config.py PYTHON_FRAMEWORK = $(PROJECT_DIR)/deps/frameworks/Python.framework -PYTHON = $(PYTHON_FRAMEWORK)/Versions/3.10/bin/python3 -LIBRARY_SEARCH_PATHS = $(inherited) $(PYTHON_FRAMEWORK)/Versions/3.10/lib/python3.10/config-3.10-darwin -HEADER_SEARCH_PATHS = $(inherited) $(PYTHON_FRAMEWORK)/Versions/3.10/include/python3.10 -OTHER_LDFLAGS = $(inherited) -lpython3.10 +PYTHON = $(PYTHON_FRAMEWORK)/Versions/3.13/bin/python3 +LIBRARY_SEARCH_PATHS = $(inherited) $(PYTHON_FRAMEWORK)/Versions/3.13/lib/python3.13/config-3.13-darwin +HEADER_SEARCH_PATHS = $(inherited) $(PYTHON_FRAMEWORK)/Versions/3.13/include/python3.13 +OTHER_LDFLAGS = $(inherited) -lpython3.13 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) PYTHON_BIN="$(PYTHON)" PY3K=1 \ No newline at end of file diff --git a/deps/extensions/svg/Makefile b/deps/extensions/svg/Makefile new file mode 100644 index 0000000..df4cbda --- /dev/null +++ b/deps/extensions/svg/Makefile @@ -0,0 +1,24 @@ +REPO := https://github.com/swhitty/SwiftDraw.git +TAG := 0.9.3 +.PHONY: all + +all: SwiftDraw.o + @: + +SwiftDraw.o: SwiftDraw + cd SwiftDraw && swift build -c release --target SwiftDraw --arch arm64 --arch x86_64 + cp SwiftDraw/.build/apple/Products/Release/SwiftDraw*.o $@ + +SwiftDraw: + git clone --depth 1 --branch $(TAG) $(REPO) + rm -r SwiftDraw/CommandLine + rm -r SwiftDraw/SwiftDrawTests + rm -r SwiftDraw/Examples + rm SwiftDraw/*.* + + cp NSImage+PlotDevice.swift SwiftDraw/SwiftDraw + cp Package.swift SwiftDraw/ + +clean: + rm -rf SwiftDraw + rm -f *.o \ No newline at end of file diff --git a/deps/extensions/svg/NSImage+PlotDevice.swift b/deps/extensions/svg/NSImage+PlotDevice.swift new file mode 100644 index 0000000..72dacca --- /dev/null +++ b/deps/extensions/svg/NSImage+PlotDevice.swift @@ -0,0 +1,35 @@ +import AppKit +import CoreGraphics + +public extension NSImage { + + convenience init?(svgData data: Data) { + guard let image = Image(data: data) else { return nil } + + self.init(size: image.size, flipped: true) { rect in + guard let ctx = NSGraphicsContext.current?.cgContext else { return false } + ctx.draw(image, in: CGRect(x: 0, y: 0, width: rect.size.width, height: rect.size.height)) + return true + } + } + + convenience init?(svgFileURL url: URL) { + guard let image = Image(fileURL: url) else { return nil } + + self.init(size: image.size, flipped: true) { rect in + guard let ctx = NSGraphicsContext.current?.cgContext else { return false } + ctx.draw(image, in: CGRect(x: 0, y: 0, width: rect.size.width, height: rect.size.height)) + return true + } + } + + @objc + static func svgFromData(_ data: Data) -> NSImage? { + NSImage(svgData: data) + } + + @objc + static func svgFromURL(_ url: URL) -> NSImage? { + NSImage(svgFileURL: url) + } +} \ No newline at end of file diff --git a/deps/extensions/svg/Package.swift b/deps/extensions/svg/Package.swift new file mode 100644 index 0000000..b4d1453 --- /dev/null +++ b/deps/extensions/svg/Package.swift @@ -0,0 +1,16 @@ +// swift-tools-version:5.4 + +import PackageDescription + +let package = Package( + name: "SwiftDraw", + platforms: [ + .macOS(.v10_12), + ], + targets: [ + .target( + name: "SwiftDraw", + dependencies: [], + path: "SwiftDraw" + )] +) diff --git a/deps/frameworks/Makefile b/deps/frameworks/Makefile index f085083..788b538 100644 --- a/deps/frameworks/Makefile +++ b/deps/frameworks/Makefile @@ -1,13 +1,20 @@ -PYTHON_VERSION = 3.10.5 +PYTHON_VERSION = 3.13.3 FRAMEWORK_REPO = https://github.com/gregneagle/relocatable-python.git BUILD_OPTS = --os-version=11 --python-version=$(PYTHON_VERSION) --upgrade-pip --pip-requirements=requirements.txt BIN = ./Python.framework/Versions/Current/bin +# Use PIP_NO_CACHE_DIR from environment if set, otherwise empty +PIP_ENV = $(if $(PIP_NO_CACHE_DIR),PIP_NO_CACHE_DIR=1,) + +# Use the framework's headers (otherwise clang defaults to looking in /Library/Frameworks/Python.framework) +PIP_INCLUDES = $(shell $(BIN)/python3-config --includes) + all: Python.framework - $(BIN)/pip3 install --upgrade ../.. + $(PIP_ENV) CFLAGS="$(PIP_INCLUDES)" $(BIN)/pip3 install --upgrade ../.. + find $(shell find ./Python.framework -name py2app -type d) -name main-\* -not -name \*universal2 -delete Python.framework: relocatable-python - python3 ./relocatable-python/make_relocatable_python_framework.py $(BUILD_OPTS) + PYTHONNOUSERSITE=1 $(PIP_ENV) python3 ./relocatable-python/make_relocatable_python_framework.py $(BUILD_OPTS) $(BIN)/python3 config.py ../../app/python.xcconfig relocatable-python: diff --git a/deps/frameworks/requirements.txt b/deps/frameworks/requirements.txt index 4823c92..150f105 100644 --- a/deps/frameworks/requirements.txt +++ b/deps/frameworks/requirements.txt @@ -1,8 +1,8 @@ # --no-binary :all: xattr -cachecontrol +cachecontrol[filecache] cffi -lockfile -pyobjc +pyobjc==11.0 +py2app requests six \ No newline at end of file diff --git a/plotdevice/__main__.py b/plotdevice/__main__.py index 94aa43e..ed9e1eb 100644 --- a/plotdevice/__main__.py +++ b/plotdevice/__main__.py @@ -25,11 +25,14 @@ Create an animated gif that loops every 2 seconds: python3 -m plotdevice script.pv --export output.gif --frames 60 --fps 30 --loop + +Installing Libraries: + python3 -m plotdevice --install urllib3 jinja2 numpy """ import sys, os, re import argparse -from os.path import exists, islink, dirname, abspath, realpath, join +from os.path import exists, islink, dirname, basename, abspath, realpath, join, splitext def main(): @@ -60,15 +63,28 @@ def main(): x = parser.add_argument_group("Export Options") x.add_argument('--export', '-o', metavar='FILE', help='a destination filename ending in pdf, eps, png, tiff, jpg, heic, gif, or mov') x.add_argument('--zoom', metavar='PERCENT', default=100, type=int, help='scale of the output image (100 = regular size) unless specified by a filename ending in @2x/@3x/etc') - o.add_argument('--cmyk', action='store_const', const=True, default=False, help='convert colors to c/m/y/k during exports') + x.add_argument('--cmyk', action='store_const', const=True, default=False, help='convert colors to c/m/y/k during exports') i = parser.add_argument_group("PlotDevice Script File", None) i.add_argument('script', help='the python script to be rendered') + p = parser.add_argument_group("Installing Packages") + p.add_argument('--install', nargs='*', default=[], metavar='package', help="Use `pip` to download libraries into the ~/Library/Application Support/PlotDevice directory, making them `import`-able in the application and by scripts run from the command line") + + if len(sys.argv)==1: parser.print_usage() print('for more detail:\n %s --help' % parser.prog) return + elif sys.argv[1] == '--install': + # --install has to be the first argument (in which case we can handle it now and bail) + libDir = os.path.join(os.getenv("HOME"), "Library", "Application Support", "PlotDevice") + if not os.path.exists(libDir): + os.mkdir(libDir) + + from subprocess import call + PIP = os.environ.pop('_p_l_o_t_d_e_v_i_c_e___p_i_p_', 'pip3') + sys.exit(call([PIP, 'install', '--isolated', '--target', libDir, *sys.argv[2:]])) opts = parser.parse_args() @@ -105,7 +121,12 @@ def main(): opts.mode = 'headless' # screen out unsupported file extensions - basename, ext = opts.export.lower().rsplit('.',1) + try: + outname, ext = opts.export.lower().rsplit('.',1) + except ValueError: + ext = opts.export.lower() + outname = splitext(basename(opts.script))[0] + opts.export = '.'.join([outname, ext]) if ext not in ('pdf', 'eps', 'png', 'jpg', 'heic', 'tiff', 'gif', 'mov'): parser.exit(1, 'bad argument [--export]\nthe output filename must end with a supported format:\n pdf, eps, png, tiff, jpg, heic, gif, or mov\n') @@ -124,11 +145,15 @@ def main(): # it's a `single' doc or a sequence of numbered pdf files opts.single = bool(ext=='pdf' and not re.search('{\d+}', opts.export) and opts.last and opts.first < opts.last) - if m:= re.search(r'@(\d+)[xX]$', basename): + if m:= re.search(r'@(\d+)[xX]$', outname): opts.zoom = float(m.group(1)) else: opts.zoom = max(0.01, opts.zoom/100) + if opts.install: + print("The --install option must be used on its own, not in combination with other flags") + sys.exit(1) + # set it off plotdevice.__all__.clear() run(vars(opts)) diff --git a/plotdevice/context.py b/plotdevice/context.py index 5f6b3d1..799a9fb 100644 --- a/plotdevice/context.py +++ b/plotdevice/context.py @@ -1777,7 +1777,7 @@ def _getImageData(self, format, zoom=1.0, cmyk=False): w, h = self.pagesize cgData = NSMutableData.data() dataConsumer = CGDataConsumerCreateWithCFData(cgData) - pdfContext = CGPDFContextCreate(dataConsumer, CGRectMake(0, 0, w, h), None) + pdfContext = CGPDFContextCreate(dataConsumer, CGRectMake(0, 0, w*zoom, h*zoom), None) CGPDFContextBeginPage(pdfContext, None) self._render_to_context(pdfContext, zoom) CGPDFContextEndPage(pdfContext) diff --git a/plotdevice/gfx/atoms.py b/plotdevice/gfx/atoms.py index 4becd30..4141fc6 100644 --- a/plotdevice/gfx/atoms.py +++ b/plotdevice/gfx/atoms.py @@ -427,7 +427,7 @@ def __init__(self, name, type, *args, **kwargs): small = min(self.min, self.max) big = max(self.min, self.max) - if not small < self.value < big: + if not small <= self.value <= big: raise DeviceError("The value %d doesn't fall with the range %d–%d" % (self.value, self.min, self.max)) elif self.type == TEXT: diff --git a/plotdevice/gfx/image.py b/plotdevice/gfx/image.py index 8825220..8817cf0 100644 --- a/plotdevice/gfx/image.py +++ b/plotdevice/gfx/image.py @@ -5,11 +5,12 @@ import warnings import math from contextlib import contextmanager +from urllib.parse import urlparse from ..lib.cocoa import * from plotdevice import DeviceError from ..util import _copy_attrs, autorelease -from ..util.readers import HTTP, last_modified +from ..util.readers import get_http_session, last_modified from ..lib.io import MovieExportSession, ImageExportSession from .geometry import Region, Size, Point, Transform, CENTER from .atoms import TransformMixin, EffectsMixin, FrameMixin, Grob @@ -76,6 +77,9 @@ def __init__(self, *args, **kwargs): else: invalid = "Not a valid image source: %r" % type(src) raise DeviceError(invalid) + else: + undefined = "Image requires either a source (path/url/Image) or image data" + raise DeviceError(undefined) # set the bounds (in phases) if isinstance(src, Image): @@ -111,20 +115,25 @@ def _lazyload(self, path=None, data=None): if key in _cache: return _cache[key][0] # ...or load from the data - image = NSImage.alloc().initWithData_(data) + if b'= mtime: return _cache[path][0] # ...or load from the data - bytes = resp.content - data = NSData.dataWithBytes_length_(bytes, len(bytes)) - image = NSImage.alloc().initWithData_(data) + data = NSData.dataWithBytes_length_(resp.content, len(resp.content)) + if 'svg' in resp.headers['Content-Type'] or urlparse(path).path.lower().endswith('.svg'): + image = NSImage.svgFromData_(data) + else: + image = NSImage.alloc().initWithData_(data) else: # load from file path try: @@ -138,7 +147,10 @@ def _lazyload(self, path=None, data=None): raise DeviceError(notfound) key = err_info = path # ...or load from the file - image = NSImage.alloc().initWithContentsOfFile_(path) + if path.lower().endswith('.svg'): + image = NSImage.svgFromURL_(NSURL.fileURLWithPath_(path)) + else: + image = NSImage.alloc().initWithContentsOfFile_(path) # if we wound up with a valid image, configure and cache the NSImage # before returning it @@ -249,7 +261,6 @@ def _draw(self): # NB: the nodebox source warns about quartz bugs triggered by drawing # EPSs to other origin points. no clue whether this still applies... - ### context manager for calls to `with export(...)` ### import time diff --git a/plotdevice/gfx/typography.py b/plotdevice/gfx/typography.py index b7790a8..cd5c383 100644 --- a/plotdevice/gfx/typography.py +++ b/plotdevice/gfx/typography.py @@ -75,7 +75,7 @@ def __repr__(self): spec.insert(2, self._face.variant) spec.insert(1, '/' if self._face.italic else '|') spec.insert(1, ("%.1fpt"%self._metrics['size']).replace('.0pt','pt')) - return ('Font(%s)'%" ".join(spec)).encode('utf-8') + return ('Font(%s)'%" ".join(spec)) def __enter__(self): if not hasattr(self, '_rollback'): @@ -256,7 +256,7 @@ def __repr__(self): n = len(getattr(self, group)) if n: contents.append('%i %s%s' % (n, group[:-1], '' if n==1 else 's')) - return ('Family(%s)'%", ".join(contents)).encode('utf-8') + return ('Family(%s)'%", ".join(contents)) @property def name(self): diff --git a/plotdevice/gui/app.py b/plotdevice/gui/app.py index d7567ae..7d31284 100644 --- a/plotdevice/gui/app.py +++ b/plotdevice/gui/app.py @@ -9,6 +9,9 @@ from . import bundle_path, set_timeout LIB_DIR_README = """"You can put PlotDevice libraries In this directory to make them available to your scripts. + +You can also install anything you find on https://pypi.org here using the command line tool: + plotdevice --install """ class PlotDeviceAppDelegate(NSObject): diff --git a/plotdevice/gui/document.py b/plotdevice/gui/document.py index 9d9b3a0..586dfa9 100644 --- a/plotdevice/gui/document.py +++ b/plotdevice/gui/document.py @@ -331,6 +331,10 @@ def windowWillClose_(self, note): def shouldCloseDocument(self): return True + def splitView_shouldAdjustSizeOfSubview_(self, splitview, subview): + # keep the size of the editor fixed and adjust the graphics view on resize + return type(subview) is not NSSplitView + # # Toolbar state # diff --git a/plotdevice/gui/editor.py b/plotdevice/gui/editor.py index 3f2393c..7d8044b 100644 --- a/plotdevice/gui/editor.py +++ b/plotdevice/gui/editor.py @@ -2,13 +2,10 @@ import os import re import json -import cgi import objc from io import open from objc import super -from pprint import pprint from time import time -from bisect import bisect from ..lib.cocoa import * from plotdevice.gui.preferences import get_default, editor_info from plotdevice.gui import bundle_path, set_timeout diff --git a/plotdevice/gui/widgets.py b/plotdevice/gui/widgets.py index 2c01afc..3c93b59 100644 --- a/plotdevice/gui/widgets.py +++ b/plotdevice/gui/widgets.py @@ -271,6 +271,12 @@ def callHandler_(self, name): var = self.script.vm.params[name] result = self.script.vm.call(var.name) self.script.echo(result.output) + if result.ok: + try: + self.script.currentView.setCanvas(self.script.vm.canvas) + except DeviceError as e: + return self.script.crash() + @objc.python_method def updateInterface(self): @@ -359,10 +365,9 @@ class ExportSheet(NSObject): def awakeFromNib(self): self.formats = dict(image=(0, 'pdf', 0,0, 'png', 'jpg', 'heic', 'tiff', 'gif', 0,0, 'pdf', 'eps'), movie=('mov', 'mov', 'gif')) self.movie = dict(format='mov', first=1, last=150, fps=30, bitrate=1, loop=0, codec=0) - self.image = dict(format='pdf', zoom=100, first=1, last=1, cmyk=False, single=True) + self.image = dict(format='pdf', zoom=1.0, first=1, last=1, cmyk=False, single=True) self.last = None - @objc.python_method def beginExport(self, kind): # configure the accessory controls @@ -458,7 +463,7 @@ def imageState(self, key=None): fmts = self.formats['image'] fmt_idx = self.imageFormat.indexOfSelectedItem() state = dict(format=fmts[fmt_idx], - zoom=self.image['zoom'] / 100, + zoom=self.image['zoom'], first=1, cmyk=self.imageCMYK.state()==NSOnState, single=fmt_idx==1, @@ -491,7 +496,7 @@ def imageZoomStepped_(self, sender): sender.setIntValue_(0) self.imageZoomChanged_(None) # reflect any editing in text field - pct = self.image['zoom'] + pct = self.image['zoom'] * 100 if step > 0: pct = 100 * ceil((pct + 1) / 100) @@ -499,16 +504,16 @@ def imageZoomStepped_(self, sender): pct = 100 * floor((pct - 1) / 100) if 0 < pct < 10000: - self.image['zoom'] = pct + self.image['zoom'] = pct / 100 self.imageZoom.setStringValue_("%i%%" % pct) @IBAction def imageZoomChanged_(self, sender): pct = self.imageZoom.intValue() if pct > 0: - self.image['zoom'] = pct + self.image['zoom'] = pct / 100 else: - pct = self.image['zoom'] + pct = self.image['zoom'] * 100 self.imageZoom.setStringValue_("%i%%" % pct) @IBAction diff --git a/plotdevice/lib/foundry.py b/plotdevice/lib/foundry.py index 9af9ee2..7efffa3 100644 --- a/plotdevice/lib/foundry.py +++ b/plotdevice/lib/foundry.py @@ -636,7 +636,7 @@ def best_fam(self, word): matches = [self._fams[corpus.index(m)] for m in in_corpus] if matches: - nomatch += '.\nDid you mean: %s'%[m.encode('utf-8') for m in matches] + nomatch += '.\nDid you mean: %s'%matches return DeviceError(nomatch) diff --git a/plotdevice/lib/pathmatics.py b/plotdevice/lib/pathmatics.py index ecf91ba..b0125b7 100644 --- a/plotdevice/lib/pathmatics.py +++ b/plotdevice/lib/pathmatics.py @@ -479,6 +479,7 @@ def findpath(points, curvature=1.0): # The list of points consists of Point objects, # but it shouldn't crash on something straightforward # such as someone supplying a list of (x,y)-tuples. + points = list(points) # copy to avoid modifying the input for i, pt in enumerate(points): if isinstance(pt, (tuple, list)): points[i] = Point(pt[0], pt[1]) diff --git a/plotdevice/run/__init__.py b/plotdevice/run/__init__.py index c1e02f3..5dc8ec2 100644 --- a/plotdevice/run/__init__.py +++ b/plotdevice/run/__init__.py @@ -5,6 +5,7 @@ try: # test the sys.path by attempting to load a PyObjC submodule... from Foundation import * + import objc except ImportError: # detect whether we're being run from the repository and set up a local env if so repo = abspath(join(dirname(__file__), '../..')) @@ -17,6 +18,7 @@ call([sys.executable, setup_py, 'dev']) site.addsitedir(local_libs) from Foundation import * + import objc else: from pprint import pformat missing = "Searched for PyObjC libraries in:\n%s\nto no avail..."%pformat(sys.path) @@ -28,4 +30,4 @@ # expose the script-runner object from .sandbox import Sandbox -__all__ = ('objc', 'encoding', 'Sandbox') \ No newline at end of file +__all__ = ('objc', 'encoded', 'Sandbox') \ No newline at end of file diff --git a/plotdevice/run/sandbox.py b/plotdevice/run/sandbox.py index 52e4462..254298f 100644 --- a/plotdevice/run/sandbox.py +++ b/plotdevice/run/sandbox.py @@ -1,7 +1,7 @@ import os, sys, re, ast from os.path import dirname, basename, abspath, relpath, isdir from functools import partial -from inspect import getargspec +from inspect import getfullargspec from collections import namedtuple from PyObjCTools import AppHelper from ..lib.io import MovieExportSession, ImageExportSession @@ -214,7 +214,7 @@ def run(self, method=None, cmyk=False): func = self.namespace.get(routine) # replace each such routine with a partial application passing # the dict. this means we can .call() it without any explicit args - if callable(func) and getargspec(func).args: + if callable(func) and getfullargspec(func).args: self.namespace[routine] = partial(self.namespace[routine], self._anim) elif method=='draw': diff --git a/plotdevice/util/ottobot.py b/plotdevice/util/ottobot.py index d025dab..295b1a1 100644 --- a/plotdevice/util/ottobot.py +++ b/plotdevice/util/ottobot.py @@ -227,7 +227,8 @@ def genTemplate(kind='sketch'): return """size(512, 512) background(1) -text("Welcome to PlotDevice", 40, 40) +rect(20, 15, WIDTH-40, 1) +text("Welcome to PlotDevice", 20, 40) """ elif kind=='anim': return """# to create an animation, call speed() with a diff --git a/plotdevice/util/readers.py b/plotdevice/util/readers.py index 9a3550f..2cdaa6e 100644 --- a/plotdevice/util/readers.py +++ b/plotdevice/util/readers.py @@ -4,7 +4,7 @@ # files & io from io import open, StringIO, BytesIO -from os.path import abspath, dirname, exists, join, splitext +from os.path import abspath, dirname, exists, join, splitext, expanduser from plotdevice import DeviceError, INTERNAL # data formats @@ -190,13 +190,25 @@ def csv_dialect(fd): ### HTTP utils ### -import requests -from cachecontrol import CacheControl, CacheControlAdapter -from cachecontrol.caches import FileCache -from cachecontrol.heuristics import LastModified +HTTP = None -cache_dir = '%s/Library/Caches/PlotDevice'%os.environ['HOME'] -HTTP = CacheControl(requests.Session(), cache=FileCache(cache_dir), heuristic=LastModified()) +def get_http_session(): + """Returns a cached HTTP session (creating it if necessary)""" + global HTTP + if HTTP is None: + try: + from cachecontrol import CacheControl + from cachecontrol.caches import FileCache + from cachecontrol.heuristics import LastModified + import requests + + cache_dir = join(expanduser('~'), 'Library/Caches/PlotDevice') + HTTP = CacheControl(requests.Session(), + cache=FileCache(cache_dir), + heuristic=LastModified()) + except ImportError as e: + raise ImportError("HTTP dependencies not available. Install with: pip install requests cachecontrol[filecache]") from e + return HTTP def binaryish(content, format): bin_types = ('pdf','eps','png','jpg','jpeg','heic','gif','tiff','tif','zip','tar','gz') @@ -244,7 +256,8 @@ def read(pth, format=None, encoding=None, cols=None, **kwargs): """ if re.match(r'https?:', pth): - resp = HTTP.get(pth) + session = get_http_session() # Get the session only when needed + resp = session.get(pth) resp.raise_for_status() extension_type = splitext(urlparse(pth).path)[-1] diff --git a/setup.py b/setup.py index c83a1f5..61b181a 100644 --- a/setup.py +++ b/setup.py @@ -20,10 +20,11 @@ # import os, sys, json, re, platform from glob import glob -from setuptools import setup, find_packages +from shutil import rmtree +from setuptools import setup, find_packages, Command from setuptools.extension import Extension -from distutils.dir_util import remove_tree -from distutils.command.build_py import build_py +from setuptools.command.build_py import build_py +from setuptools.command.build_ext import build_ext from pkg_resources import DistributionNotFound from os.path import join, exists, dirname, basename, abspath, getmtime from subprocess import call, getoutput @@ -39,14 +40,13 @@ AUTHOR = plotdevice.__author__ AUTHOR_EMAIL = plotdevice.__email__ LICENSE = plotdevice.__license__ -URL = "http://plotdevice.io/" +URL = "https://plotdevice.io/" CLASSIFIERS = [ "Development Status :: 5 - Production/Stable", "Environment :: MacOS X :: Cocoa", "Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: MIT License", "Operating System :: MacOS :: MacOS X", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", @@ -63,17 +63,36 @@ "Topic :: Text Editors :: Integrated Development Environments (IDE)", ] DESCRIPTION = "Create two-dimensional graphics and animations with code" -LONG_DESCRIPTION = """PlotDevice is a Macintosh application used for computational graphic design. It provides an -interactive Python environment where you can create two-dimensional graphics -and output them in a variety of vector, bitmap, and animation formats. It is -meant both as a sketch environment for exploring generative design and as a -general purpose graphics library for use in stand-alone Python programs. +LONG_DESCRIPTION = """PlotDevice is a Macintosh application used for computational graphic design. It +provides an interactive Python environment where you can create two-dimensional +graphics and output them in a variety of vector, bitmap, and animation formats. +It is meant both as a sketch environment for exploring generative design and as +a general purpose graphics library for use in external Python programs. + +PlotDevice scripts can create images from simple geometric primitives, text, and +external vector or bitmap images. Drawing commands provide a thin abstraction +over macOS's Quartz graphics engine, providing high-quality rendering +of 2D imagery and powerful compositing operations. PlotDevice is a fork of NodeBox 1.9.7rc1 with support for modern versions of Python and Mac OS. The new version features: +* Runs natively on Intel and Apple Silicon and supports retina displays +* Python 3 only (including a bundled 3.10 installation in the app) +* images can now be exported in HEIC format and videos support H.265 (HEVC) +* SVG files can now be drawn to the canvas using the `image()` command (thanks to the magical [SwiftDraw](https://github.com/swhitty/SwiftDraw) library) +* image exports have a configurable `zoom` to create 2x/3x/etc ‘retina’ images +* revamped `var()` command for creating GUIs to modify values via sliders, buttons, toggles, etc. +* updated text editor with multiple tabs, new themes, and additional key-binding modes emulating Sublime Text and VS Code +* the module's command line interface is now accessible through `python3 -m plotdevice` +* the command line tool has a new `--install` option to download [PyPI](https://pypi.org) packages for use within the app +* document autosaving is now user-configurable +* Bugfixes and misc. improvements detailed in the [changelog](https://github.com/plotdevice/plotdevice/blob/main/CHANGES.md) + +Version 0.9.5 added: + * Python 3 compatible * Can now be built with system Python or Homebrew versions of the interpreter * Much faster import times on Yosemite thanks to a bundled copy of PyObjC 3.0.4 @@ -152,7 +171,7 @@ def gosub(cmd, on_err=True): def timestamp(): from datetime import datetime - datetime.now().strftime("%a, %d %b %Y %H:%M:%S") + return datetime.now().strftime("%a, %d %b %Y %H:%M:%S") def stale(dst, src): if exists(src): @@ -161,34 +180,64 @@ def stale(dst, src): ## Build Commands ## -from distutils.core import Command class CleanCommand(Command): - description = "wipe out the ./build & ./dist dirs and other setup-generated files" - user_options = [] + description = "wipe out generated files and build artifacts" + user_options = [ + ('dist', None, 'also remove Python.framework and local dependencies'), + ] + def initialize_options(self): - pass + self.dist = None + def finalize_options(self): pass + def run(self): - os.system('rm -rf ./build ./dist') - os.system('rm -rf plotdevice.egg-info MANIFEST.in PKG') - os.system('rm -rf ./tests/_out ./tests/_diff ./details.html') - os.system('rm -f ./_plotdevice.*.so') - os.system('find plotdevice -name .DS_Store -exec rm {} \;') - os.system('find plotdevice -name \*.pyc -exec rm {} \;') - os.system('find plotdevice -name __pycache__ -type d -prune -exec rmdir {} \;') - -class DistCleanCommand(Command): + paths = [ + 'build', + 'dist', + '*.egg', + '*.egg-info', + '.eggs', + 'MANIFEST.in', + 'PKG', + 'tests/_out', + 'tests/_diff', + 'details.html', + '_plotdevice.*.so', + '**/*.pyc', + '**/__pycache__', + '**/.DS_Store', + ] + + # Add framework paths if --dist flag is used + if self.dist: + paths.extend([ + 'deps/local', + 'deps/frameworks/Python.framework', + 'deps/frameworks/relocatable-python', + ]) + + for path_pattern in paths: + for path in glob(path_pattern, recursive=True): + if exists(path): + print('removing %s'%path) + if os.path.isdir(path): + rmtree(path) + else: + os.unlink(path) + + # Run make clean in svg extensions dir + if exists('deps/extensions/svg'): + os.system('cd deps/extensions/svg && make clean') + +class DistCleanCommand(CleanCommand): + """Alias for `clean --dist` for backward compatibility""" description = "delete Python.framework, local pypi dependencies, and all generated files" - user_options = [] + def initialize_options(self): - pass - def finalize_options(self): - pass - def run(self): - self.run_command('clean') - os.system('rm -rf ./deps/local') - os.system('rm -rf ./deps/frameworks/*.framework') + super().initialize_options() + self.dist = True # always do a deep clean class LocalDevCommand(Command): description = "set up environment to allow for running `python -m plotdevice` within the repo" @@ -205,7 +254,7 @@ def run(self): import venv venv.create(venv_dir, symlinks=True, with_pip=True) PIP = '%s/bin/pip3' % venv_dir - call([PIP, 'install', '-q', '--upgrade', 'pip', 'wheel', 'py2app']) + call([PIP, 'install', '-q', '--upgrade', 'pip', 'wheel', 'py2app', 'twine']) call([PIP, '--isolated', 'install', *config['install_requires']]) # place the compiled c-extensions in the main repo dir @@ -213,9 +262,7 @@ def run(self): build_ext.inplace = 1 self.run_command('build_ext') - # build the sdist (primarily for access to its rsrc subdir) - self.run_command('build_py') - + print("\nA local development environment has been set up in %s" % venv_dir) from setuptools.command.sdist import sdist class BuildDistCommand(sdist): @@ -227,6 +274,7 @@ def finalize_options(self): prune app/Resources/ui include app/plotdevice include deps/extensions/*/*.h + recursive-include deps/extensions/svg *.swift Makefile include tests/*.py graft tests/_in graft examples @@ -240,11 +288,14 @@ def run(self): for dst, src in stale('app/Resources/viewer.nib', "app/Resources/en.lproj/PlotDeviceScript.xib"): self.spawn(['/usr/bin/ibtool','--compile', dst, src]) + # make sure we have the sources for SwiftDraw + call('cd deps/extensions/svg && make SwiftDraw', shell=True) + # build the sdist based on our MANIFEST additions sdist.run(self) # clean up - remove_tree('plotdevice.egg-info') + rmtree('plotdevice.egg-info') os.unlink('MANIFEST.in') class BuildCommand(build_py): @@ -266,6 +317,10 @@ def run(self): self.spawn(['/usr/bin/ibtool','--compile', nib, xib]) self.copy_file(nib, rsrc_dir) +class BuildExtCommand(build_ext): + def run(self): + call('cd deps/extensions/svg && make', shell=True) + build_ext.run(self) class TestCommand(Command): description = "Run unit tests" @@ -282,18 +337,24 @@ def run(self): class BuildAppCommand(Command): description = "Build PlotDevice.app with xcode" - user_options = [] + user_options = [ + ('no-cache', None, 'do not use pip cache when installing dependencies'), + ] + def initialize_options(self): - pass + self.no_cache = None def finalize_options(self): # make sure the embedded framework exists (and has updated app/python.xcconfig) print("Set up Python.framework for app build") - call('cd deps/frameworks && make', shell=True) + env = os.environ.copy() + if self.no_cache: + env['PIP_NO_CACHE_DIR'] = '1' + call('cd deps/frameworks && make', shell=True, env=env) def run(self): self.spawn(['xcodebuild', '-configuration', 'Release']) - remove_tree('dist/PlotDevice.app.dSYM') + rmtree('dist/PlotDevice.app.dSYM') print("done building PlotDevice.app in ./dist") try: @@ -357,7 +418,7 @@ def run(self): update_plist(info_pth, CFBundleVersion = last_commit(), CFBundleShortVersionString = VERSION, - SUFeedURL = 'http://plotdevice.io/app.xml', + SUFeedURL = 'https://plotdevice.io/app.xml', SUEnableSystemProfiling = 'YES' ) @@ -397,14 +458,12 @@ def codesign(root, name=None, exec=False, entitlement=False): codesign('%s/Versions/Current/bin'%PYTHON, name="python3.*", entitlement=True) codesign('%s/Versions/Current/Resources/Python.app'%PYTHON, entitlement=True) codesign(PYTHON) - self.spawn(['codesign', '--verify', '-vv', PYTHON]) codesign('%s/Versions/Current/Updater.app'%SPARKLE) codesign(SPARKLE) - self.spawn(['codesign', '--verify', '-vv', SPARKLE]) - codesign(APP) - self.spawn(['codesign', '--verify', '-vv', APP]) + codesign(APP, entitlement=True) + self.spawn(['codesign', '--verify', '--deep', '-vv', APP]) # create versioned zipfile of the app & notarize it self.spawn(['ditto', '-ck', '--keepParent', APP, ZIP]) @@ -429,6 +488,7 @@ def codesign(root, name=None, exec=False, entitlement=False): version = VERSION, description = DESCRIPTION, long_description = LONG_DESCRIPTION, + long_description_content_type = 'text/markdown', author = AUTHOR, author_email = AUTHOR_EMAIL, url = URL, @@ -438,24 +498,25 @@ def codesign(root, name=None, exec=False, entitlement=False): ext_modules = [Extension( '_plotdevice', sources = ['deps/extensions/module.m', *glob('deps/extensions/*/*.[cm]')], + extra_objects=['deps/extensions/svg/SwiftDraw.o'], extra_link_args=sum((['-framework', fmwk] for fmwk in ['AppKit', 'Foundation', 'Quartz', 'Security', 'AVFoundation', 'CoreMedia', 'CoreVideo', 'CoreText'] ), []) )], install_requires = [ 'requests', - 'cachecontrol', - 'lockfile', - 'pyobjc-core==8.5', - 'pyobjc-framework-Quartz==8.5', - 'pyobjc-framework-LaunchServices==8.5', - 'pyobjc-framework-WebKit==8.5', + 'cachecontrol[filecache]', + 'pyobjc-core==11.0', + 'pyobjc-framework-Quartz==11.0', + 'pyobjc-framework-LaunchServices==11.0', + 'pyobjc-framework-WebKit==11.0', ], scripts = ["app/plotdevice"], zip_safe=False, cmdclass={ 'app': BuildAppCommand, 'build_py': BuildCommand, + 'build_ext': BuildExtCommand, 'clean': CleanCommand, 'distclean': DistCleanCommand, 'dist': DistCommand, @@ -472,6 +533,9 @@ def codesign(root, name=None, exec=False, entitlement=False): # (this means the various commands don't have to play path games) os.chdir(dirname(abspath(__file__))) + # clear away any finder droppings that may have accumulated + call('find . -name .DS_Store -delete', shell=True) + # py2app-specific config if 'py2app' in sys.argv: config.update(dict( diff --git a/tests/compositing.py b/tests/compositing.py index 6c73e1b..5db3274 100644 --- a/tests/compositing.py +++ b/tests/compositing.py @@ -111,5 +111,5 @@ def test_beginclip(self): def suite(): suite = unittest.TestSuite() - suite.addTest(unittest.makeSuite(CompositingTests)) + suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(CompositingTests)) return suite diff --git a/tests/drawing.py b/tests/drawing.py index 8769369..1d0dbe1 100644 --- a/tests/drawing.py +++ b/tests/drawing.py @@ -479,5 +479,5 @@ def test_strokewidth(self): def suite(): suite = unittest.TestSuite() - suite.addTest(unittest.makeSuite(DrawingTests)) + suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(DrawingTests)) return suite diff --git a/tests/geometry.py b/tests/geometry.py index cbbcec2..4819798 100644 --- a/tests/geometry.py +++ b/tests/geometry.py @@ -247,5 +247,5 @@ def test_push(self): def suite(): suite = unittest.TestSuite() - suite.addTest(unittest.makeSuite(GeometryTests)) + suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(GeometryTests)) return suite diff --git a/tests/module.py b/tests/module.py index 9a13100..77a30bb 100644 --- a/tests/module.py +++ b/tests/module.py @@ -35,9 +35,9 @@ def test_cli(self): def suite(): - from unittest import TestSuite, makeSuite + from unittest import TestSuite, defaultTestLoader suite = TestSuite() - suite.addTest(makeSuite(ModuleTests)) + suite.addTest(defaultTestLoader.loadTestsFromTestCase(ModuleTests)) return suite \ No newline at end of file diff --git a/tests/primitives.py b/tests/primitives.py index 1c31dd3..4fcf41c 100644 --- a/tests/primitives.py +++ b/tests/primitives.py @@ -160,5 +160,5 @@ def test_star(self): def suite(): suite = unittest.TestSuite() - suite.addTest(unittest.makeSuite(PrimitivesTests)) + suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(PrimitivesTests)) return suite diff --git a/tests/typography.py b/tests/typography.py index bc30adb..2f777dc 100644 --- a/tests/typography.py +++ b/tests/typography.py @@ -494,5 +494,5 @@ def test_line_fragment(self): def suite(): suite = unittest.TestSuite() - suite.addTest(unittest.makeSuite(TypographyTests)) + suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(TypographyTests)) return suite