Skip to content

Expand type annotations and add classes for path item handling - #412

Merged
loonghao merged 38 commits into
loonghao:mainfrom
pappnu:type-annotation
Oct 1, 2026
Merged

loonghao merged 38 commits into
loonghao:mainfrom
pappnu:type-annotation

Conversation

@pappnu

@pappnu pappnu commented Aug 12, 2025 •

Copy link
Copy Markdown
Contributor

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

  • Removes Application.compareWithNumbers, Application.system and Session.echo functions as they simply wrapped basic Python actions without adding any extra functionality to them, so I assumed they aren't really necessary.
  • getByName now returns None instead 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.

Member Change Migration
ActionDescriptor.toStream Removed Use ActionDescriptor.toStream()-equivalent handling via ActionDescriptor.getData()/putData(), or the raw COM object.
EPSOpenOptions.embedColorProfile Removed Use PhotoshopSaveOptions/EPSSaveOptions.embedColorProfile at save time instead of open time.
ArtLayers.parent Removed Use ArtLayer.parent on the individual layer.
LayerComps.parent Removed Use LayerComp.parent on the individual comp.
ArtLayer.add Removed Not meaningful on a single layer; use ArtLayers.add() on the collection.
ArtLayer.length Removed Not meaningful on a single layer; use len(ArtLayers).
Channel.remove Renamed Use Channel.delete(), which matches the rest of the API.

Photoshop.__getattribute__ COM passthrough

Every 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 shipping py.typed and 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 annotations is set across the package, and the 16 sites that are evaluated eagerly (collection base-class subscripts and a TypeVar bound) use typing.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

  • Added automated tests for the new collection base classes (BaseCollection, CollectionOfNamedObjects, CollectionOfRemovables, CollectionWithAdd) and for the getByName contract, using a stand-in automation object so they run without Photoshop.
  • The manual tests under test/manual_test were 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.
  • Collected tests go from 1 to 167 (66 run without Photoshop); verified green on Python 3.8, 3.9 and 3.12.

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.

@pappnu

pappnu commented May 26, 2026

Copy link
Copy Markdown
Contributor Author

@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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (83 > 79 characters)

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (101 > 79 characters)

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (99 > 79 characters)

"""
dup = self.app.duplicate(relativeObject, insertionLocation)
return ArtLayer(dup)
return ArtLayer(self.app.duplicate(relativeObject.app if relativeObject else None, insertionLocation))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (110 > 79 characters)

self.app.applyOceanRipple(size, magnitude)

def applyOffset(self, horizontal, vertical, undefinedAreas):
def applyOffset(self, horizontal: int, vertical: int, undefinedAreas: OffsetUndefinedAreas) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (104 > 79 characters)

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (86 > 79 characters)

self.app.splitChannels()

def suspendHistory(self, historyString, javaScriptString):
def suspendHistory(self, historyString: str, javaScriptString: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (80 > 79 characters)

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (91 > 79 characters)

self.app.rasterizeAllLayers()

def recordMeasurements(self, source, dataPoints):
def recordMeasurements(self, source: MeasurementSource, dataPoints: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (85 > 79 characters)

Comment thread photoshop/api/_document.py Outdated
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":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (96 > 79 characters)

Comment thread photoshop/api/_core.py Outdated
except OSError as exc:
err = exc
if err:
self._logger.debug(f"Failed to create Photoshop Application object. Tried versions {versions}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (111 > 79 characters)

Comment thread photoshop/api/_core.py
return []

def _get_application_object(self, versions: List[str] = None) -> Optional[Dispatch]:
def _get_application_object(self, versions: list[str] | None = None) -> FullyDynamicDispatch:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (97 > 79 characters)

Comment thread photoshop/api/_core.py Outdated
def __repr__(self):
return self
self.adobe = app
self.app: Any = parent.app if isinstance(parent, Photoshop) else parent

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (83 > 79 characters)

Comment thread photoshop/api/_core.py Outdated
try:
app = self._get_application_object(versions)
except OSError as err:
raise PhotoshopPythonAPIError("Please check if you have Photoshop installed correctly.") from err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (113 > 79 characters)

Comment thread photoshop/api/_core.py
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 ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (91 > 79 characters)

Comment thread photoshop/api/_layer.py Outdated
def link(self, with_layer: "Layer") -> None:
self.app.link(with_layer.app)

def move(self, relativeObject: "Layer | LayerSet", insertionLocation: ElementPlacement) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (100 > 79 characters)

Comment thread photoshop/api/_layer.py
Returns:
Layer: The duplicated layer.
"""
return Layer(self.app.duplicate(relativeObject.app if relativeObject else None, insertionLocation))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Black would make changes.
line too long (107 > 79 characters)

Comment thread photoshop/api/_layer.py

@property
def opacity(self) -> float:
"""The layer's master opacity (as a percentage). Range: 0.0 to 100.0."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (80 > 79 characters)

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (93 > 79 characters)

relativeObject: Layer | None = None,
insertionLocation: ElementPlacement | None = None,
):
return LayerSet(self.app.duplicate(relativeObject.app if relativeObject else None, insertionLocation))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (110 > 79 characters)

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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (104 > 79 characters)


"""
return self.app.stroke(strokeColor, width, location, mode, opacity, preserveTransparency)
self.app.stroke(strokeColor.app, width, location, mode, opacity, preserveTransparency)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (94 > 79 characters)


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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (100 > 79 characters)

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (89 > 79 characters)

display_dialogs: DialogModes = DialogModes.DisplayNoDialogs,
) -> ActionDescriptor:
return ActionDescriptor(
self.app.executeAction(event_id, descriptor.app if descriptor else None, display_dialogs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (101 > 79 characters)

limit,
javascript,
)
def doProgressSubTask(self, index: int, limit: int, javascript: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (81 > 79 characters)

javascript,
)
def doProgressSegmentTask(self, segmentLength: int, done: int, total: int, javascript: str) -> None:
script = f"app.doProgressSegmentTask({segmentLength}, {done}, {total}, '{javascript}');"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line too long (96 > 79 characters)

pappnu added 25 commits October 1, 2026 13:55
…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.
pappnu and others added 9 commits October 1, 2026 14:02
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.
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.
@loonghao
loonghao merged commit 869c6a4 into loonghao:main Oct 1, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants