shortcutkit 0.7.2__tar.gz → 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: shortcutkit
3
+ Version: 1.0.0
4
+ Summary: Build, validate, and sign Apple Shortcuts files from Python. All 434 built-in actions and 1,449 Apple app actions, extracted from the Shortcuts engine.
5
+ Author: frontboat
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/frontboat/shortcutkit
8
+ Project-URL: Documentation, https://github.com/frontboat/shortcutkit/blob/main/docs/shortcut-file-format.md
9
+ Project-URL: Repository, https://github.com/frontboat/shortcutkit.git
10
+ Project-URL: Changelog, https://github.com/frontboat/shortcutkit/blob/main/CHANGELOG.md
11
+ Keywords: apple,apple-shortcuts,shortcuts,shortcuts-app,workflowkit,workflow,automation,macos,macos-automation,ios,plist
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # shortcutkit
23
+
24
+ Build, validate, and sign Apple Shortcuts (`.shortcut`) files from Python.
25
+
26
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/frontboat/shortcutkit/blob/main/LICENSE)
27
+ [![CI](https://img.shields.io/github/actions/workflow/status/frontboat/shortcutkit/ci.yml?branch=main)](https://github.com/frontboat/shortcutkit/actions)
28
+
29
+ A shortcut that asks your name and says hello:
30
+
31
+ ```python
32
+ from shortcutkit import Shortcut, actions, ref, text
33
+
34
+ s = Shortcut("Greeting")
35
+ name = s.action(actions.ASK, WFAskActionPrompt="What's your name?")
36
+ s.action(actions.SHOWRESULT, Text=text("Hello, ", ref(name), "!"))
37
+
38
+ s.write() # Greeting.shortcut
39
+ Shortcut.sign("Greeting.shortcut", "Greeting-signed.shortcut")
40
+ ```
41
+
42
+ Open `Greeting-signed.shortcut` and Shortcuts adds it to your library. Shortcuts only imports
43
+ signed files, and signing needs a Mac with an iCloud login; everything else runs on any OS.
44
+
45
+ ## Why
46
+
47
+ The action catalog is extracted from the Shortcuts app's own frameworks, not written by hand,
48
+ and one command refreshes it after a macOS update. Earlier libraries kept hand-written lists and
49
+ are archived ([python-shortcuts](https://github.com/alexander-akhmetov/python-shortcuts),
50
+ [shortcuts-js](https://github.com/joshfarrant/shortcuts-js));
51
+ [Cherri](https://github.com/electrikmilk/cherri) is a separate language with its own compiler.
52
+
53
+ - All 434 built-in actions as constants, so a typo is an `AttributeError`.
54
+ - 1,449 actions from Apple's apps: Reminders, Notes, Messages, Settings, and 73 more.
55
+ - Third-party app actions, given their descriptor.
56
+ - If, Repeat, Repeat with Each, and Choose from Menu blocks.
57
+ - A wrong key or value raises `ValueError` before anything is written.
58
+ - Pure Python, no dependencies.
59
+
60
+ A [TypeScript package](https://www.npmjs.com/package/shortcutkit) with the same name and data
61
+ adds compile-time checks.
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ pip install shortcutkit # Python 3.9+
67
+ ```
68
+
69
+ ## Usage
70
+
71
+ These examples build on the one at the top.
72
+
73
+ ### Share sheet
74
+
75
+ ```python
76
+ from shortcutkit import shortcut_input
77
+
78
+ s = Shortcut("Page Links", types=["ActionExtension"], input_classes=["WFSafariWebPageContentItem"])
79
+ links = s.action(actions.RUNJAVASCRIPTONWEBPAGE, WFInput=shortcut_input(), WFJavaScript="completion(document.links.length)")
80
+ s.action(actions.SHOWRESULT, Text=text("Links: ", ref(links)))
81
+ ```
82
+
83
+ ### Control flow
84
+
85
+ ```python
86
+ gid = s.if_(ref(name), "has_any_value")
87
+ s.action(actions.SHOWRESULT, Text=text("Hello, ", ref(name)))
88
+ s.otherwise(gid)
89
+ s.action(actions.SHOWRESULT, Text=text("Hello, stranger"))
90
+ s.end_if(gid)
91
+ ```
92
+
93
+ ### Apple's apps
94
+
95
+ ```python
96
+ r = s.action(actions.REMINDERS_CREATE_REMINDER, title=text("Buy milk"), priorityLevel="high")
97
+ s.action(actions.NOTES_CREATE_NOTE, name="Shopping", content=text("Added ", ref(r)))
98
+ ```
99
+
100
+ ### Other apps
101
+
102
+ Pass the identifier and descriptor yourself; the parameters are not checked.
103
+
104
+ ```python
105
+ s.action(
106
+ "com.example.app.CreateNoteIntent",
107
+ AppIntentDescriptor={
108
+ "BundleIdentifier": "com.example.app", "Name": "Example", "TeamIdentifier": "ABCDE12345",
109
+ "AppIntentIdentifier": "CreateNoteIntent", "ActionRequiresAppInstallation": True,
110
+ },
111
+ title=text("Hello"),
112
+ body=ref(name),
113
+ )
114
+ ```
115
+
116
+ ## API
117
+
118
+ | Export | What it does |
119
+ |---|---|
120
+ | `Shortcut(name, color=, glyph=, types=, input_classes=)` | A shortcut. |
121
+ | `s.action(id, **params)` | Appends an action and returns it. |
122
+ | `s.if_(subject, condition, value=None)`, `s.otherwise(gid)`, `s.end_if(gid)` | An If block. |
123
+ | `s.repeat_each(items)`, `s.end_repeat_each(gid)` | A Repeat with Each block. |
124
+ | `s.repeat_count(n)`, `s.end_repeat_count(gid)` | A Repeat block. |
125
+ | `s.choose_from_menu(prompt, items)`, `s.menu_item(gid, title)`, `s.end_menu(gid)` | A Choose from Menu block. |
126
+ | `s.write(path=None)` | Writes the unsigned file and returns its path. |
127
+ | `Shortcut.sign(src, dst, mode="anyone")` | Signs a file with `shortcuts sign`. |
128
+ | `s.to_plist()` | The shortcut as a plist dictionary. |
129
+ | `ref(action)` | An action's output. |
130
+ | `text(*parts)` | A string with references in it. |
131
+ | `variable(name)`, `repeat_item()`, `repeat_index()` | Named and repeat variables. |
132
+ | `shortcut_input()`, `clipboard()`, `current_date()`, `ask()`, `device_details()`, `current_app()` | Special variables. |
133
+ | `actions`, `ACTIONS`, `get_action(id)` | Action identifiers and their catalog entries. |
134
+ | `PARAM_KINDS`, `PARAM_CHOICES`, `PARAM_VARIABLE_TYPES` | What each parameter accepts. |
135
+ | `CONDITION`, `ICON_COLORS`, `PROVENANCE` | If conditions, icon colors, and the build the data came from. |
136
+
137
+ ## Documentation
138
+
139
+ - [Shortcut file format](https://github.com/frontboat/shortcutkit/blob/main/docs/shortcut-file-format.md)
140
+ - [Built-in actions](https://github.com/frontboat/shortcutkit/blob/main/docs/builtin-actions-reference.md)
141
+ - [Apple app actions](https://github.com/frontboat/shortcutkit/blob/main/docs/apple-app-intents-reference.md)
142
+ - [Parameter encodings](https://github.com/frontboat/shortcutkit/blob/main/docs/parameter-encodings.md)
143
+ - [How the data was extracted](https://github.com/frontboat/shortcutkit/blob/main/docs/extraction.md),
144
+ and what is still approximate
145
+
146
+ ## Contributing
147
+
148
+ Issues and pull requests are welcome. See
149
+ [`CONTRIBUTING.md`](https://github.com/frontboat/shortcutkit/blob/main/CONTRIBUTING.md).
150
+
151
+ ## License
152
+
153
+ [MIT](https://github.com/frontboat/shortcutkit/blob/main/LICENSE).
@@ -0,0 +1,132 @@
1
+ # shortcutkit
2
+
3
+ Build, validate, and sign Apple Shortcuts (`.shortcut`) files from Python.
4
+
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/frontboat/shortcutkit/blob/main/LICENSE)
6
+ [![CI](https://img.shields.io/github/actions/workflow/status/frontboat/shortcutkit/ci.yml?branch=main)](https://github.com/frontboat/shortcutkit/actions)
7
+
8
+ A shortcut that asks your name and says hello:
9
+
10
+ ```python
11
+ from shortcutkit import Shortcut, actions, ref, text
12
+
13
+ s = Shortcut("Greeting")
14
+ name = s.action(actions.ASK, WFAskActionPrompt="What's your name?")
15
+ s.action(actions.SHOWRESULT, Text=text("Hello, ", ref(name), "!"))
16
+
17
+ s.write() # Greeting.shortcut
18
+ Shortcut.sign("Greeting.shortcut", "Greeting-signed.shortcut")
19
+ ```
20
+
21
+ Open `Greeting-signed.shortcut` and Shortcuts adds it to your library. Shortcuts only imports
22
+ signed files, and signing needs a Mac with an iCloud login; everything else runs on any OS.
23
+
24
+ ## Why
25
+
26
+ The action catalog is extracted from the Shortcuts app's own frameworks, not written by hand,
27
+ and one command refreshes it after a macOS update. Earlier libraries kept hand-written lists and
28
+ are archived ([python-shortcuts](https://github.com/alexander-akhmetov/python-shortcuts),
29
+ [shortcuts-js](https://github.com/joshfarrant/shortcuts-js));
30
+ [Cherri](https://github.com/electrikmilk/cherri) is a separate language with its own compiler.
31
+
32
+ - All 434 built-in actions as constants, so a typo is an `AttributeError`.
33
+ - 1,449 actions from Apple's apps: Reminders, Notes, Messages, Settings, and 73 more.
34
+ - Third-party app actions, given their descriptor.
35
+ - If, Repeat, Repeat with Each, and Choose from Menu blocks.
36
+ - A wrong key or value raises `ValueError` before anything is written.
37
+ - Pure Python, no dependencies.
38
+
39
+ A [TypeScript package](https://www.npmjs.com/package/shortcutkit) with the same name and data
40
+ adds compile-time checks.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install shortcutkit # Python 3.9+
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ These examples build on the one at the top.
51
+
52
+ ### Share sheet
53
+
54
+ ```python
55
+ from shortcutkit import shortcut_input
56
+
57
+ s = Shortcut("Page Links", types=["ActionExtension"], input_classes=["WFSafariWebPageContentItem"])
58
+ links = s.action(actions.RUNJAVASCRIPTONWEBPAGE, WFInput=shortcut_input(), WFJavaScript="completion(document.links.length)")
59
+ s.action(actions.SHOWRESULT, Text=text("Links: ", ref(links)))
60
+ ```
61
+
62
+ ### Control flow
63
+
64
+ ```python
65
+ gid = s.if_(ref(name), "has_any_value")
66
+ s.action(actions.SHOWRESULT, Text=text("Hello, ", ref(name)))
67
+ s.otherwise(gid)
68
+ s.action(actions.SHOWRESULT, Text=text("Hello, stranger"))
69
+ s.end_if(gid)
70
+ ```
71
+
72
+ ### Apple's apps
73
+
74
+ ```python
75
+ r = s.action(actions.REMINDERS_CREATE_REMINDER, title=text("Buy milk"), priorityLevel="high")
76
+ s.action(actions.NOTES_CREATE_NOTE, name="Shopping", content=text("Added ", ref(r)))
77
+ ```
78
+
79
+ ### Other apps
80
+
81
+ Pass the identifier and descriptor yourself; the parameters are not checked.
82
+
83
+ ```python
84
+ s.action(
85
+ "com.example.app.CreateNoteIntent",
86
+ AppIntentDescriptor={
87
+ "BundleIdentifier": "com.example.app", "Name": "Example", "TeamIdentifier": "ABCDE12345",
88
+ "AppIntentIdentifier": "CreateNoteIntent", "ActionRequiresAppInstallation": True,
89
+ },
90
+ title=text("Hello"),
91
+ body=ref(name),
92
+ )
93
+ ```
94
+
95
+ ## API
96
+
97
+ | Export | What it does |
98
+ |---|---|
99
+ | `Shortcut(name, color=, glyph=, types=, input_classes=)` | A shortcut. |
100
+ | `s.action(id, **params)` | Appends an action and returns it. |
101
+ | `s.if_(subject, condition, value=None)`, `s.otherwise(gid)`, `s.end_if(gid)` | An If block. |
102
+ | `s.repeat_each(items)`, `s.end_repeat_each(gid)` | A Repeat with Each block. |
103
+ | `s.repeat_count(n)`, `s.end_repeat_count(gid)` | A Repeat block. |
104
+ | `s.choose_from_menu(prompt, items)`, `s.menu_item(gid, title)`, `s.end_menu(gid)` | A Choose from Menu block. |
105
+ | `s.write(path=None)` | Writes the unsigned file and returns its path. |
106
+ | `Shortcut.sign(src, dst, mode="anyone")` | Signs a file with `shortcuts sign`. |
107
+ | `s.to_plist()` | The shortcut as a plist dictionary. |
108
+ | `ref(action)` | An action's output. |
109
+ | `text(*parts)` | A string with references in it. |
110
+ | `variable(name)`, `repeat_item()`, `repeat_index()` | Named and repeat variables. |
111
+ | `shortcut_input()`, `clipboard()`, `current_date()`, `ask()`, `device_details()`, `current_app()` | Special variables. |
112
+ | `actions`, `ACTIONS`, `get_action(id)` | Action identifiers and their catalog entries. |
113
+ | `PARAM_KINDS`, `PARAM_CHOICES`, `PARAM_VARIABLE_TYPES` | What each parameter accepts. |
114
+ | `CONDITION`, `ICON_COLORS`, `PROVENANCE` | If conditions, icon colors, and the build the data came from. |
115
+
116
+ ## Documentation
117
+
118
+ - [Shortcut file format](https://github.com/frontboat/shortcutkit/blob/main/docs/shortcut-file-format.md)
119
+ - [Built-in actions](https://github.com/frontboat/shortcutkit/blob/main/docs/builtin-actions-reference.md)
120
+ - [Apple app actions](https://github.com/frontboat/shortcutkit/blob/main/docs/apple-app-intents-reference.md)
121
+ - [Parameter encodings](https://github.com/frontboat/shortcutkit/blob/main/docs/parameter-encodings.md)
122
+ - [How the data was extracted](https://github.com/frontboat/shortcutkit/blob/main/docs/extraction.md),
123
+ and what is still approximate
124
+
125
+ ## Contributing
126
+
127
+ Issues and pull requests are welcome. See
128
+ [`CONTRIBUTING.md`](https://github.com/frontboat/shortcutkit/blob/main/CONTRIBUTING.md).
129
+
130
+ ## License
131
+
132
+ [MIT](https://github.com/frontboat/shortcutkit/blob/main/LICENSE).
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "shortcutkit"
7
- version = "0.7.2"
8
- description = "Build, validate and sign Apple Shortcuts (.shortcut) files."
7
+ version = "1.0.0"
8
+ description = "Build, validate, and sign Apple Shortcuts files from Python. All 434 built-in actions and 1,449 Apple app actions, extracted from the Shortcuts engine."
9
9
  requires-python = ">=3.9"
10
10
  readme = "README.md"
11
11
  license = "MIT"
@@ -25,9 +25,11 @@ Value helpers return the exact structures WorkflowKit's state classes serialize:
25
25
  current_date() WFTextTokenAttachment -> current date
26
26
  ask("Prompt") WFTextTokenAttachment -> ask each time
27
27
  text("a ", ref(x)) WFTextTokenString -> text with embedded references
28
- picker(att) {"Type": "Variable", "Variable": att} (variable-picker parameters)
28
+ subject(att) {"Type": "Variable", "Variable": att} (the If condition's subject; pickers take the bare attachment)
29
29
  Plain str / int / float / bool / list are written as-is.
30
30
  """
31
+ import copy
32
+ import functools
31
33
  import json
32
34
  import pathlib
33
35
  import plistlib
@@ -38,6 +40,13 @@ import uuid
38
40
  HERE = pathlib.Path(__file__).resolve().parent
39
41
  DEFINITIONS = HERE / "data" / "builtin-actions.json"
40
42
 
43
+
44
+ @functools.lru_cache(maxsize=None)
45
+ def _load_definitions(path):
46
+ """The definitions file, parsed once per path; {} when it is absent."""
47
+ path = pathlib.Path(path)
48
+ return json.loads(path.read_text()) if path.exists() else {}
49
+
41
50
  # WFWorkflowIcon.backgroundColorValue for palette colors 0-14, as the unsigned 32-bit
42
51
  # value the plist stores. Names follow Shortcuts' picker order.
43
52
  ICON_COLORS = {
@@ -95,7 +104,9 @@ def _check_kind(kind, value):
95
104
  if kind in ("string", "text", "plainString") and not isinstance(value, str):
96
105
  return f"expected a string{'' if kind == 'plainString' else ' or a reference'}, got {type(value).__name__}"
97
106
  if kind == "picker":
98
- return "expected picker(<attachment>) or an attachment"
107
+ return "expected a reference (ref(), variable(), shortcut_input(), ...)"
108
+ if kind == "subject" and not (isinstance(value, dict) and value.get("Type") == "Variable" and "Variable" in value):
109
+ return "expected a reference or subject(<reference>)"
99
110
  return None
100
111
 
101
112
 
@@ -104,31 +115,59 @@ def _attachment(value):
104
115
 
105
116
 
106
117
  def ref(action, output_name=None):
107
- """Reference another action's output. `action` is the dict returned by Shortcut.action()."""
118
+ """Another action's output. `action` is what Shortcut.action() returned."""
108
119
  return _attachment({"Type": "ActionOutput", "OutputUUID": action["WFWorkflowActionParameters"]["UUID"],
109
120
  "OutputName": output_name or action.get("_outputName", "Output")})
110
121
 
111
122
 
112
123
  def variable(name):
124
+ """A named variable, as set by Set Variable or Add to Variable."""
113
125
  return _attachment({"Type": "Variable", "VariableName": name})
114
126
 
115
127
 
116
128
  def shortcut_input():
129
+ """The shortcut's input, from the share sheet, Quick Actions, or Run Shortcut."""
117
130
  return _attachment({"Type": "ExtensionInput"})
118
131
 
119
132
 
120
133
  def clipboard():
134
+ """The clipboard's contents."""
121
135
  return _attachment({"Type": "Clipboard"})
122
136
 
123
137
 
124
138
  def current_date():
139
+ """The date and time the shortcut runs."""
125
140
  return _attachment({"Type": "CurrentDate"})
126
141
 
127
142
 
128
143
  def ask(prompt=None):
144
+ """A value the user is asked for each time the shortcut runs."""
129
145
  return _attachment({"Type": "Ask", **({"Prompt": prompt} if prompt else {})})
130
146
 
131
147
 
148
+ def device_details():
149
+ """Device Details (name, model, OS version, ...); the app usually adds a property aggrandizement."""
150
+ return _attachment({"Type": "DeviceDetails"})
151
+
152
+
153
+ def current_app():
154
+ """The app the shortcut was run from."""
155
+ return _attachment({"Type": "CurrentApp"})
156
+
157
+
158
+ def _reference_types(value):
159
+ """The variable types (Ask, ActionOutput, Variable, ...) referenced anywhere in a value."""
160
+ if not isinstance(value, dict):
161
+ return []
162
+ if value.get("WFSerializationType") == "WFTextTokenAttachment":
163
+ return [str(value.get("Value", {}).get("Type"))]
164
+ if value.get("WFSerializationType") == "WFTextTokenString":
165
+ return [str(a.get("Type")) for a in value.get("Value", {}).get("attachmentsByRange", {}).values()]
166
+ if value.get("Type") == "Variable" and isinstance(value.get("Variable"), dict):
167
+ return _reference_types(value["Variable"])
168
+ return []
169
+
170
+
132
171
  def repeat_item():
133
172
  """The current item inside a Repeat with Each block."""
134
173
  return _attachment({"Type": "Variable", "VariableName": "Repeat Item"})
@@ -139,13 +178,19 @@ def repeat_index():
139
178
  return _attachment({"Type": "Variable", "VariableName": "Repeat Index"})
140
179
 
141
180
 
181
+ def subject(attachment):
182
+ """The If-subject form of a reference. if_() and action() apply it for you."""
183
+ return {"Type": "Variable", "Variable": attachment}
184
+
185
+
142
186
  def picker(attachment):
187
+ """Deprecated, misnamed: this is the If-subject form. Pass a bare reference to picker parameters."""
143
188
  """Wrap an attachment for WFVariablePickerParameter keys (e.g. WFInput on Repeat with Each)."""
144
189
  return {"Type": "Variable", "Variable": attachment}
145
190
 
146
191
 
147
192
  def text(*parts):
148
- """A WFTextTokenString: strings and attachments interleaved. Each attachment becomes U+FFFC."""
193
+ """Text with references in it: text("Hello, ", ref(name), "!")."""
149
194
  string = ""
150
195
  attachments = {}
151
196
  for part in parts:
@@ -159,20 +204,27 @@ def text(*parts):
159
204
 
160
205
 
161
206
  class Shortcut:
207
+ """A shortcut. Add actions, then write() and sign(); Shortcuts names the import after the file,
208
+ which defaults to <name>.shortcut."""
209
+
162
210
  def __init__(self, name, color="Blue", glyph=DEFAULT_GLYPH, input_classes=None, types=None, definitions=DEFINITIONS):
163
- """types: surfaces the shortcut appears in (WFWorkflowTypes): "ActionExtension" is the share
164
- sheet, "QuickActions" the Finder and Services menus, also "MenuBar", "NCWidget", "WatchKit",
165
- "Sleep", "ReceivesOnScreenContent". input_classes: content item classes accepted as input."""
211
+ """color: a palette name ("Teal") or a raw value. glyph: a codepoint from Shortcuts' icon font.
212
+ types: where the shortcut appears: "ActionExtension" (share sheet), "QuickActions" (Finder and
213
+ Services menus), "MenuBar", "NCWidget", "WatchKit", "Sleep", "ReceivesOnScreenContent".
214
+ input_classes: content item classes it accepts as input."""
166
215
  self.name = name
167
216
  self.color = ICON_COLORS[color] if isinstance(color, str) else color
168
217
  self.glyph = glyph
169
218
  self.input_classes = input_classes or []
170
219
  self.types = list(types or [])
171
220
  self.actions = []
172
- self.defs = json.load(open(definitions)) if pathlib.Path(definitions).exists() else {}
221
+ self.defs = _load_definitions(definitions)
173
222
 
174
- def action(self, identifier, **params):
175
- """Append an action. Unknown identifiers or parameter keys raise ValueError."""
223
+ def action(self, identifier, /, **params):
224
+ """Append an action and return it; ref() it to use its output. Catalog identifiers are
225
+ checked: unknown keys, wrong value kinds, and unreadable references raise ValueError. Other
226
+ identifiers pass through unchecked. `identifier` is positional-only, so a parameter can
227
+ have that name."""
176
228
  definition = self.defs.get(identifier)
177
229
  catalog = ACTIONS.get(identifier) or {}
178
230
  descriptor = catalog.get("descriptor")
@@ -192,13 +244,20 @@ class Shortcut:
192
244
  if unknown and known - {"UUID", "GroupingIdentifier", "WFControlFlowMode", "CustomOutputName"}:
193
245
  raise ValueError(f"{identifier}: unknown parameter(s) {sorted(unknown)}; known: {sorted(known)}")
194
246
  kinds = PARAM_KINDS.get(identifier, {})
247
+ restricted = PARAM_VARIABLE_TYPES.get(identifier, {})
195
248
  for key, value in params.items():
196
249
  problem = _check_kind(kinds.get(key, "any"), value)
197
250
  if problem:
198
251
  raise ValueError(f"{identifier}.{key}: {problem}")
252
+ # Keys the engine reads no reference for (or only Ask): the same rule as the TypeScript types, at run time.
253
+ if key in restricted:
254
+ bad = [t for t in _reference_types(value) if t not in restricted[key]]
255
+ if bad:
256
+ raise ValueError(f"{identifier}.{key}: the engine does not read a {'/'.join(bad)} reference here (it reads {', '.join(restricted[key]) if restricted[key] else 'only a plain value'})")
199
257
  # Text parameters never hold a bare attachment: the engine writes a lone variable as a
200
258
  # token string with one placeholder. Wrap it so ref() works for text keys too.
201
- params = {k: (text(v) if kinds.get(k) == "text" and isinstance(v, dict) and v.get("WFSerializationType") == "WFTextTokenAttachment" else v)
259
+ params = {k: (text(v) if kinds.get(k) == "text" and _is_reference(v) and v.get("WFSerializationType") == "WFTextTokenAttachment"
260
+ else subject(v) if kinds.get(k) == "subject" and _is_reference(v) and v.get("WFSerializationType") == "WFTextTokenAttachment" else v)
202
261
  for k, v in params.items()}
203
262
  # App Intents actions carry a descriptor naming the app that provides them.
204
263
  head = {"UUID": str(uuid.uuid4()).upper()}
@@ -216,9 +275,15 @@ class Shortcut:
216
275
 
217
276
  # Control flow: each block is a group sharing a GroupingIdentifier, with
218
277
  # WFControlFlowMode 0 = start, 1 = middle (Otherwise / menu item), 2 = end.
278
+ @staticmethod
279
+ def _subject(value):
280
+ return value if isinstance(value, dict) and value.get("Type") == "Variable" and "Variable" in value else subject(value)
281
+
219
282
  def if_(self, subject, condition, value=None):
283
+ """Open an If block on `subject`. `condition` is a CONDITION name or code; `value` is what it
284
+ compares with, if anything. Returns the id for otherwise() and end_if()."""
220
285
  gid = str(uuid.uuid4()).upper()
221
- params = {"GroupingIdentifier": gid, "WFControlFlowMode": 0, "WFInput": picker(subject),
286
+ params = {"GroupingIdentifier": gid, "WFControlFlowMode": 0, "WFInput": self._subject(subject),
222
287
  "WFCondition": CONDITION[condition] if isinstance(condition, str) else condition}
223
288
  if isinstance(value, (int, float)) and not isinstance(value, bool):
224
289
  params["WFNumberValue"] = value
@@ -228,49 +293,55 @@ class Shortcut:
228
293
  return gid
229
294
 
230
295
  def otherwise(self, gid):
296
+ """Start the Otherwise branch of If block `gid`."""
231
297
  self.action("is.workflow.actions.conditional", GroupingIdentifier=gid, WFControlFlowMode=1)
232
298
 
233
299
  def end_if(self, gid):
300
+ """Close If block `gid`."""
234
301
  self.action("is.workflow.actions.conditional", GroupingIdentifier=gid, WFControlFlowMode=2)
235
302
 
236
303
  def repeat_each(self, items):
237
- """Open a Repeat with Each block; returns the grouping identifier to pass to end_repeat_each()."""
304
+ """Open a Repeat with Each block over `items`; returns the id for end_repeat_each(). Inside,
305
+ use repeat_item() and repeat_index()."""
238
306
  gid = str(uuid.uuid4()).upper()
239
- self.action("is.workflow.actions.repeat.each", GroupingIdentifier=gid, WFControlFlowMode=0, WFInput=picker(items))
307
+ self.action("is.workflow.actions.repeat.each", GroupingIdentifier=gid, WFControlFlowMode=0, WFInput=items)
240
308
  return gid
241
309
 
242
310
  def end_repeat_each(self, gid):
243
- """Close a Repeat with Each block. The returned action is the block's output: ref() it for "Repeat Results"."""
311
+ """Close a Repeat with Each block and return it; ref() it for the Repeat Results."""
244
312
  a = self.action("is.workflow.actions.repeat.each", GroupingIdentifier=gid, WFControlFlowMode=2)
245
313
  a["_outputName"] = "Repeat Results"
246
314
  return a
247
315
 
248
316
  def repeat_count(self, count):
249
- """Open a Repeat block that runs `count` times; returns the grouping identifier for end_repeat_count()."""
317
+ """Open a Repeat block that runs `count` times; returns the id for end_repeat_count()."""
250
318
  gid = str(uuid.uuid4()).upper()
251
319
  self.action("is.workflow.actions.repeat.count", GroupingIdentifier=gid, WFControlFlowMode=0, WFRepeatCount=count)
252
320
  return gid
253
321
 
254
322
  def end_repeat_count(self, gid):
255
- """Close a Repeat block. The returned action is the block's output: ref() it for "Repeat Results"."""
323
+ """Close a Repeat block and return it; ref() it for the Repeat Results."""
256
324
  a = self.action("is.workflow.actions.repeat.count", GroupingIdentifier=gid, WFControlFlowMode=2)
257
325
  a["_outputName"] = "Repeat Results"
258
326
  return a
259
327
 
260
328
  def choose_from_menu(self, prompt, items):
261
- """Open a Choose from Menu block. Follow with one menu_item() per title, in order, each with
262
- the actions of that branch, then end_menu(). Returns the grouping identifier."""
329
+ """Open a Choose from Menu block; returns the id for menu_item() and end_menu(). Follow with
330
+ one menu_item() per title, in order, each followed by that branch's actions."""
263
331
  gid = str(uuid.uuid4()).upper()
264
332
  self.action("is.workflow.actions.choosefrommenu", GroupingIdentifier=gid, WFControlFlowMode=0, WFMenuPrompt=prompt, WFMenuItems=list(items))
265
333
  return gid
266
334
 
267
335
  def menu_item(self, gid, title):
336
+ """Start the branch for `title` in menu `gid`."""
268
337
  self.action("is.workflow.actions.choosefrommenu", GroupingIdentifier=gid, WFControlFlowMode=1, WFMenuItemTitle=title)
269
338
 
270
339
  def end_menu(self, gid):
340
+ """Close menu `gid`."""
271
341
  self.action("is.workflow.actions.choosefrommenu", GroupingIdentifier=gid, WFControlFlowMode=2)
272
342
 
273
343
  def to_plist(self):
344
+ """The shortcut as a plist dictionary."""
274
345
  actions = []
275
346
  for a in self.actions:
276
347
  actions.append({k: v for k, v in a.items() if not k.startswith("_")})
@@ -291,8 +362,7 @@ class Shortcut:
291
362
  }
292
363
 
293
364
  def write(self, path=None):
294
- """Write the unsigned plist. Shortcuts names an imported shortcut after its file, so the
295
- default file name is the shortcut's name."""
365
+ """Write the unsigned file, <name>.shortcut by default, and return its path."""
296
366
  path = pathlib.Path(path) if path else pathlib.Path(f"{self.name}.shortcut")
297
367
  with open(path, "wb") as f:
298
368
  plistlib.dump(self.to_plist(), f, fmt=plistlib.FMT_BINARY)
@@ -300,13 +370,15 @@ class Shortcut:
300
370
 
301
371
  @staticmethod
302
372
  def sign(src, dst, mode="anyone"):
373
+ """Sign `src` into `dst`, which Shortcuts will import, and return `dst`. Needs a Mac with an
374
+ iCloud login."""
303
375
  subprocess.run(["shortcuts", "sign", "--mode", mode, "--input", str(src), "--output", str(dst)], check=True)
304
376
  return dst
305
377
 
306
378
 
307
379
  def get_action(identifier, definitions=DEFINITIONS):
308
- """What the catalogue knows about any identifier, built-in or App Intent, in one shape:
309
- name, params, kinds, output, descriptor (App Intents) and definition (built-ins)."""
380
+ """What the catalog knows about any identifier, built-in or App Intent: name, params, kinds,
381
+ output, descriptor (App Intents), and definition (built-ins)."""
310
382
  entry = ACTIONS.get(identifier)
311
383
  if entry is None:
312
384
  return None
@@ -314,9 +386,9 @@ def get_action(identifier, definitions=DEFINITIONS):
314
386
  "kinds": dict(PARAM_KINDS.get(identifier, {})), "output": entry.get("output"), "outputTypes": list(entry.get("outputTypes", []))}
315
387
  if entry.get("descriptor"):
316
388
  out["descriptor"] = entry["descriptor"]
317
- defs = json.load(open(definitions)) if definitions and pathlib.Path(definitions).exists() else {}
389
+ defs = _load_definitions(definitions) if definitions else {}
318
390
  if identifier in defs:
319
- out["definition"] = defs[identifier]
391
+ out["definition"] = copy.deepcopy(defs[identifier])
320
392
  return out
321
393
 
322
394
 
@@ -338,11 +410,11 @@ def demo(out):
338
410
 
339
411
 
340
412
  from . import actions # noqa: E402 (generated identifier constants and ACTIONS metadata)
341
- from .actions import ACTIONS, PARAM_KINDS, PARAM_CHOICES # noqa: E402
413
+ from .actions import ACTIONS, PARAM_KINDS, PARAM_CHOICES, PARAM_VARIABLE_TYPES # noqa: E402
342
414
 
343
415
  PROVENANCE = json.load(open(HERE / "data" / "provenance.json")) if (HERE / "data" / "provenance.json").exists() else {}
344
416
  """Which macOS and Shortcuts build the bundled data was extracted from."""
345
417
 
346
- __all__ = ["actions", "ACTIONS", "PARAM_KINDS", "PARAM_CHOICES", "PROVENANCE", "get_action", "repeat_item", "repeat_index", "Shortcut", "ref", "variable", "shortcut_input", "clipboard", "current_date", "ask", "picker", "text",
418
+ __all__ = ["actions", "ACTIONS", "PARAM_KINDS", "PARAM_CHOICES", "PARAM_VARIABLE_TYPES", "PROVENANCE", "get_action", "repeat_item", "repeat_index", "subject", "Shortcut", "ref", "variable", "shortcut_input", "clipboard", "current_date", "ask", "device_details", "current_app", "picker", "text",
347
419
  "ICON_COLORS", "CONDITION", "DEFAULT_GLYPH", "LEGACY_KEYS", "demo"]
348
- __version__ = "0.7.2"
420
+ __version__ = "1.0.0"