diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f17785d..77ac5b3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,6 +15,7 @@ jobs: - name: Run Tests run: | + pip3 install -U pip wheel twine python3 setup.py test - name: Build sdist @@ -23,11 +24,9 @@ jobs: - name: Upload to PyPI env: - TWINE_USERNAME: samizdat - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - TWINE_REPOSITORY: https://pypi.org/legacy/ + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} TWINE_NON_INTERACTIVE: 1 run: | - pip3 install twine twine check dist/* twine upload dist/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fde13dc..e051121 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,8 @@ jobs: id: repo uses: actions/checkout@v3 - - name: Build PyPI Package - run: | - python3 setup.py test + - name: Add missing setuptools + run: brew install python-setuptools + + - name: Run tests + run: python3 setup.py test 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/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 index 46f3738..df4cbda 100644 --- a/deps/extensions/svg/Makefile +++ b/deps/extensions/svg/Makefile @@ -7,7 +7,7 @@ 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 . + cp SwiftDraw/.build/apple/Products/Release/SwiftDraw*.o $@ SwiftDraw: git clone --depth 1 --branch $(TAG) $(REPO) 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/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/image.py b/plotdevice/gfx/image.py index 9e3915c..8817cf0 100644 --- a/plotdevice/gfx/image.py +++ b/plotdevice/gfx/image.py @@ -10,7 +10,7 @@ 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 @@ -77,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): @@ -120,7 +123,7 @@ def _lazyload(self, path=None, data=None): if re.match(r'https?:', path): # load from url key = err_info = path - resp = HTTP.get(path) + resp = get_http_session().get(path) mtime = last_modified(resp) # return a cached image if possible... if path in _cache and _cache[path][1] >= mtime: 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 397e3d7..3c93b59 100644 --- a/plotdevice/gui/widgets.py +++ b/plotdevice/gui/widgets.py @@ -365,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 @@ -464,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, @@ -497,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) @@ -505,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/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/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 749ea83..61b181a 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,6 @@ "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", @@ -182,33 +181,63 @@ def stale(dst, src): ## Build Commands ## 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('cd deps/extensions/svg && make clean') - 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" @@ -308,14 +337,20 @@ 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']) @@ -470,12 +505,11 @@ def codesign(root, name=None, exec=False, entitlement=False): )], 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, 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