Expand type annotations and add classes for path item handling - #412
Conversation
|
@loonghao The hound bot seems to complain about valid overload definitions and its documentation suggests that it is using a very old version of Flake8. Should it be removed from the project? I assume that there would be more up to date alternatives, though I'm not familiar with such tools. The pull request workflows also check at least partially the same things that the hound bot does. |
| resolution (int): The resolution of the document (in pixels per inch) | ||
| width (float): The width of the document. Non-integer values are converted to integers. | ||
| height (float): The height of the document. Non-integer values are converted to integers. | ||
| resolution (float): The resolution of the document (in pixels per inch) |
| height (int): The height of the document. | ||
| resolution (int): The resolution of the document (in pixels per inch) | ||
| width (float): The width of the document. Non-integer values are converted to integers. | ||
| height (float): The height of the document. Non-integer values are converted to integers. |
| width (int): The width of the document. | ||
| height (int): The height of the document. | ||
| resolution (int): The resolution of the document (in pixels per inch) | ||
| width (float): The width of the document. Non-integer values are converted to integers. |
| """ | ||
| dup = self.app.duplicate(relativeObject, insertionLocation) | ||
| return ArtLayer(dup) | ||
| return ArtLayer(self.app.duplicate(relativeObject.app if relativeObject else None, insertionLocation)) |
| self.app.applyOceanRipple(size, magnitude) | ||
|
|
||
| def applyOffset(self, horizontal, vertical, undefinedAreas): | ||
| def applyOffset(self, horizontal: int, vertical: int, undefinedAreas: OffsetUndefinedAreas) -> None: |
| resolution: The resolution (in pixels per inch) | ||
| automatic: Value for automatic. | ||
| resampleMethod: The downsample method. | ||
| amount: Amount of noise value when using preserve details (range: 0 - 100) |
| self.app.splitChannels() | ||
|
|
||
| def suspendHistory(self, historyString, javaScriptString): | ||
| def suspendHistory(self, historyString: str, javaScriptString: str) -> None: |
| asCopy: Saves the document as a copy, leaving the original open. | ||
| """ | ||
| return self.app.saveAs(file_path, options, asCopy, extensionType) | ||
| self.app.saveAs(file_path, options.app if options else None, asCopy, extensionType) |
| self.app.rasterizeAllLayers() | ||
|
|
||
| def recordMeasurements(self, source, dataPoints): | ||
| def recordMeasurements(self, source: MeasurementSource, dataPoints: str) -> None: |
| self.app.export(file_path, exportAs, options.app) | ||
|
|
||
| def duplicate(self, name=None, merge_layers_only=False): | ||
| def duplicate(self, name: str | None = None, merge_layers_only: bool = False) -> "Document": |
| except OSError as exc: | ||
| err = exc | ||
| if err: | ||
| self._logger.debug(f"Failed to create Photoshop Application object. Tried versions {versions}") |
| return [] | ||
|
|
||
| def _get_application_object(self, versions: List[str] = None) -> Optional[Dispatch]: | ||
| def _get_application_object(self, versions: list[str] | None = None) -> FullyDynamicDispatch: |
| def __repr__(self): | ||
| return self | ||
| self.adobe = app | ||
| self.app: Any = parent.app if isinstance(parent, Photoshop) else parent |
| try: | ||
| app = self._get_application_object(versions) | ||
| except OSError as err: | ||
| raise PhotoshopPythonAPIError("Please check if you have Photoshop installed correctly.") from err |
| ps_version = os.getenv("PS_VERSION", ps_version) | ||
| self._app_id = PHOTOSHOP_VERSION_MAPPINGS.get(ps_version, "") | ||
| self._has_parent, self.adobe, self.app = False, None, None | ||
| self._app_id = PHOTOSHOP_VERSION_MAPPINGS.get(ps_version, "") if ps_version else "" |
| def link(self, with_layer: "Layer") -> None: | ||
| self.app.link(with_layer.app) | ||
|
|
||
| def move(self, relativeObject: "Layer | LayerSet", insertionLocation: ElementPlacement) -> None: |
| Returns: | ||
| Layer: The duplicated layer. | ||
| """ | ||
| return Layer(self.app.duplicate(relativeObject.app if relativeObject else None, insertionLocation)) |
There was a problem hiding this comment.
Black would make changes.
line too long (107 > 79 characters)
|
|
||
| @property | ||
| def opacity(self) -> float: | ||
| """The layer's master opacity (as a percentage). Range: 0.0 to 100.0.""" |
| len_before = self.length | ||
| # For some reason the self.app.add returns the first layer comp, | ||
| # which might not be the new one, so we have to get the new comp in a roundabout way. | ||
| self.app.add(name, comment, appearance, position, visibility, childLayerCompStat) |
There was a problem hiding this comment.
Black would make changes.
line too long (89 > 79 characters)
| ) -> LayerComp: | ||
| len_before = self.length | ||
| # For some reason the self.app.add returns the first layer comp, | ||
| # which might not be the new one, so we have to get the new comp in a roundabout way. |
| relativeObject: Layer | None = None, | ||
| insertionLocation: ElementPlacement | None = None, | ||
| ): | ||
| return LayerSet(self.app.duplicate(relativeObject.app if relativeObject else None, insertionLocation)) |
| def enabledChannels(self, value): | ||
| self.app.enabledChannels = value | ||
| def enabledChannels(self, value: Sequence[Channel] | Channels) -> None: | ||
| self.app.enabledChannels = value.app if isinstance(value, Channels) else [channel.app for channel in value] |
There was a problem hiding this comment.
Black would make changes.
line too long (115 > 79 characters)
|
|
||
| def add(self, event, event_file: Optional[Any] = None, event_class: Optional[Any] = None) -> Notifier: | ||
| self.parent.notifiersEnabled = True | ||
| def add(self, event: str, event_file: str, event_class: str | None = None) -> Notifier: |
There was a problem hiding this comment.
Black would make changes.
line too long (91 > 79 characters)
| self.app.smooth(radius) | ||
|
|
||
| def store(self, into, combination=SelectionType.ReplaceSelection): | ||
| def store(self, into: Channel, combination: SelectionType = SelectionType.ReplaceSelection) -> None: |
|
|
||
| """ | ||
| return self.app.stroke(strokeColor, width, location, mode, opacity, preserveTransparency) | ||
| self.app.stroke(strokeColor.app, width, location, mode, opacity, preserveTransparency) |
|
|
||
| def putCustomOptions(self, key, custom_object, persistent): | ||
| self.app.putCustomOptions(key, custom_object, persistent) | ||
| def putCustomOptions(self, key: str, custom_object: ActionDescriptor, persistent: bool) -> None: |
| as_smart_object: bool = False, | ||
| ) -> Document: | ||
| document = self.app.open(document_file_path, document_type, as_smart_object) | ||
| document = self.app.open(str(document_file_path), document_type, as_smart_object) |
| display_dialogs: DialogModes = DialogModes.DisplayNoDialogs, | ||
| ) -> ActionDescriptor: | ||
| return ActionDescriptor( | ||
| self.app.executeAction(event_id, descriptor.app if descriptor else None, display_dialogs) |
| limit, | ||
| javascript, | ||
| ) | ||
| def doProgressSubTask(self, index: int, limit: int, javascript: str) -> None: |
| javascript, | ||
| ) | ||
| def doProgressSegmentTask(self, segmentLength: int, done: int, total: int, javascript: str) -> None: | ||
| script = f"app.doProgressSegmentTask({segmentLength}, {done}, {total}, '{javascript}');" |
…add various missing properties BREAKING CHANGE: Uses syntax that requires Python 3.10, removes echo, compareWithNumbers and system functions, which simply wrapped basic Python constructs without adding any extra functionality, and makes getByName return None instead of throwing when no match is found.
…ugh they are converted to integers
…date dependencies
…m any child object
Ruff does basically everything that the aforementioned tools do, but more efficiently and with less configuration.
The rebased Photoshop class annotates _create_object_from_class_id and _get_application_object with Dispatch, which the refactor dropped from the runtime imports. Annotations are evaluated at def time, so the module raised NameError on import and took every test down with it. Adding the __future__ import makes all annotations lazy.
Raising the floor to 3.10 was not load-bearing: no 3.10-only syntax is used, so the annotations can stay and the floor can stay at main's >=3.8,<4.0. Bumping it is split into its own change. - Add `from __future__ import annotations` across the package so the PEP 604/585 annotations are never evaluated at runtime. - Replace the 15 `int | str` base-class subscripts and the TypeVar bound with typing.Union: base-class subscripts are evaluated when the class is created, which the future import cannot defer. - Replace contextlib.AbstractContextManager["Session"] with typing.ContextManager["Session"]; the contextlib class only became subscriptable in 3.9. - Scope comtypes, wheel, pylint, pytest, mypy and pre-commit by Python version in pyproject.toml and regenerate poetry.lock, because comtypes >=1.4.13 and the modern dev tools require 3.9 or newer and would otherwise make the package uninstallable on 3.8.
The rewrite touched ~4300 lines while CI collected a single test. The manual tests under test/manual_test were never collected: they match neither default discovery pattern and carried no skip marker, so they have never executed. - Add test/test_collections.py covering BaseCollection, CollectionOfNamedObjects, CollectionOfRemovables and CollectionWithAdd against a fake automation object, including the getByName contract of returning None instead of raising. - Collect manual_test_*.py from pytest and skip the directory when no responsive Photoshop instance is present, so the tests are visible to CI instead of silently absent. - Ignore the two manual scripts that run their side effects at import time rather than defining tests. - Restore the 3.8/3.9 lanes in pythonpackage.yml and the 3.8-3.12 lanes in import-test.yml. The branch had narrowed both matrices, so its green CI said nothing about the Python floor. Collected tests go from 1 to 167, of which 66 run without Photoshop.
0640b0a to
66258df
Compare
The pyproject.toml declares the project in a [project] table, which Poetry 1.x cannot read: it fails with "The fields ['authors', 'description', 'name', 'version'] are required in package mode" before any test runs. CI installed whatever pip resolved for the matrix version, so only the lanes that happened to get Poetry 2.x ran. Restoring the 3.8/3.9 lanes made this visible, because those are the lanes that resolve to Poetry 1.8.5.
The PEP 621 [project] table cannot be used on the 3.8 lane: the earliest poetry release that reads it is 2.0, which requires Python >=3.9, while the newest release installable on 3.8 is 1.8.5, which rejects the file with "The fields ['authors', 'description', 'name', 'version'] are required in package mode". No single poetry version satisfies both. Declaring the metadata in [tool.poetry] again, as main does, is readable by every poetry release, so each lane can install the newest poetry its interpreter supports. requires-python stays at >=3.8,<4.0 and the classifiers are widened to match. This also restores the jsonpath that release-please-config.json bumps ($.tool.poetry.version), which the [project] table had broken.
pytest calls pytest_collection_modifyitems once per conftest with every item collected anywhere in the run, not just the ones below the conftest's directory. Marking that list unconditionally therefore skipped the whole session, including the headless tests added in the previous commit: all five CI lanes reported 167 collected / 167 skipped, so no test body ever executed and the green Import Test was vacuous. The marker is now applied only to items under test/manual_test, and the availability probe is skipped entirely when nothing in this directory was collected. Running the suite from the repository root, as CI does, now reports 66 passed / 101 skipped instead of 167 skipped. Also drop the Python 3.13 and 3.14 classifiers. Both CI matrices stop at 3.12, so advertising them on PyPI would promise support that no lane verifies; they can come back once a lane covers them. Finally align the three ruff declarations on 0.16.9. pyproject and pre-commit pinned 0.15.17 while CI installed 0.16.9, but `poetry run ruff` resolves to the copy in the poetry venv, so the 0.15.17 pin silently became the effective linter and the CI install was inert.
getByName documents that it returns the *first* element with the given
name, but the suite could not tell a first match from a last match: the
two elements used both carried the same name, and the fake derived all
of its state from that name. The only negative case was "missing" versus
"alpha", which is too distant to catch a looser predicate.
Give FakeItem a tag that is deliberately excluded from equality, so two
elements can share a name while remaining distinguishable, and assert
which of them comes back. Add near-miss negatives for a prefix ("alph")
and for case folding ("ALPHA") so the predicate cannot be widened to
prefix or case-insensitive matching unnoticed.
Mutation testing: prefix matching, case-insensitive matching and
last-instead-of-first all went from survived to killed, with always-true
and always-None positive controls killed throughout.
This PR adds extensive type annotations, various missing properties, classes for interacting with path items and tries to lessen some of the repetition within the code. The type annotations don't cover everything, especially things that would have required creating new wrapper classes. The annotations are mostly based on Photoshop Scripting Reference and Photoshop VBS Scripting Reference 2020.
Breaking Changes
Application.compareWithNumbers,Application.systemandSession.echofunctions as they simply wrapped basic Python actions without adding any extra functionality to them, so I assumed they aren't really necessary.getByNamenow returnsNoneinstead of throwing, as I find that easier to work with in a strictly typed codebase.Removed public API
Beyond the three functions above, these members are removed or renamed. Please check your code against this list before upgrading.
ActionDescriptor.toStreamActionDescriptor.toStream()-equivalent handling viaActionDescriptor.getData()/putData(), or the raw COM object.EPSOpenOptions.embedColorProfilePhotoshopSaveOptions/EPSSaveOptions.embedColorProfileat save time instead of open time.ArtLayers.parentArtLayer.parenton the individual layer.LayerComps.parentLayerComp.parenton the individual comp.ArtLayer.addArtLayers.add()on the collection.ArtLayer.lengthlen(ArtLayers).Channel.removeChannel.delete(), which matches the rest of the API.Photoshop.__getattribute__COM passthroughEvery attribute access used to fall back to the wrapped COM object, so missing or misspelled members resolved at runtime. That fallback made static analysis ineffective: a type checker saw an untyped
__getattribute__and accepted any attribute, which defeats the point of shippingpy.typedand the annotations added here.The passthrough is now guarded behind
if not TYPE_CHECKING. Runtime behaviour is unchanged, so existing code that relies on COM members which are not yet hand-declared keeps working. Type checkers only see the explicitly declared surface, which is what makes the new annotations meaningful.If you relied on a COM member that is not declared yet, it still works at runtime; please open an issue so it can be declared and typed.
Python support
The floor stays at the current
>=3.8,<4.0. The annotations use PEP 604/585 syntax, but it is never evaluated at runtime:from __future__ import annotationsis set across the package, and the 16 sites that are evaluated eagerly (collection base-class subscripts and aTypeVarbound) usetyping.Union. Bumping the floor is left to a separate change.The 3.8 and 3.9 CI lanes this PR had narrowed are restored, so the matrix covers the full supported range again.
Testing
BaseCollection,CollectionOfNamedObjects,CollectionOfRemovables,CollectionWithAdd) and for thegetByNamecontract, using a stand-in automation object so they run without Photoshop.test/manual_testwere never collected: they matched neither default discovery pattern and had no skip marker. They are now collected and skipped automatically when no responsive Photoshop instance is present, so they are visible to CI instead of silently absent.There's still many untested properties and functions, so bugs are quite likely.
This might also help with #405.
Please let me know what further changes would be required to get this merged.