shortcutkit 0.1.0__py3-none-any.whl

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,275 @@
1
+ #!/usr/bin/env python3
2
+ """shortcutkit: build, validate and sign Apple Shortcuts files from Python.
3
+
4
+ The file format is the one Shortcuts.app uses (verified against Apple's gallery
5
+ workflows in WorkflowKit and against this Mac's library). Actions and parameter keys
6
+ are validated against _builtin-actions.json produced by extract-builtin-actions.sh.
7
+
8
+ from shortcutkit import Shortcut, ref, text, ask, shortcut_input, clipboard, variable
9
+
10
+ s = Shortcut("Demo", color="Teal", glyph=0xF000)
11
+ stored = s.action("is.workflow.actions.setstoredcontent",
12
+ WFInput="hello", WFStoredContentKey="greeting", WFStoredContentGlobalValue=False)
13
+ got = s.action("is.workflow.actions.getstoredcontent", WFStoredContentKey="greeting")
14
+ s.action("is.workflow.actions.showresult", Text=text("Stored: ", ref(got)))
15
+ s.write("Demo.shortcut") # unsigned plist
16
+ s.sign("Demo.shortcut", "Demo-signed.shortcut") # runs `shortcuts sign --mode anyone`
17
+
18
+ Command line: python -m shortcutkit demo OUT.shortcut (writes and signs the demo above)
19
+
20
+ Value helpers return the exact structures WorkflowKit's state classes serialize:
21
+ ref(action) WFTextTokenAttachment -> that action's output
22
+ variable("Name") WFTextTokenAttachment -> a named variable
23
+ shortcut_input() WFTextTokenAttachment -> the shortcut's input
24
+ clipboard() WFTextTokenAttachment -> clipboard
25
+ current_date() WFTextTokenAttachment -> current date
26
+ ask("Prompt") WFTextTokenAttachment -> ask each time
27
+ text("a ", ref(x)) WFTextTokenString -> text with embedded references
28
+ picker(att) {"Type": "Variable", "Variable": att} (variable-picker parameters)
29
+ Plain str / int / float / bool / list are written as-is.
30
+ """
31
+ import json
32
+ import pathlib
33
+ import plistlib
34
+ import subprocess
35
+ import sys
36
+ import uuid
37
+
38
+ HERE = pathlib.Path(__file__).resolve().parent
39
+ DEFINITIONS = HERE / "data" / "builtin-actions.json"
40
+
41
+ # WFWorkflowIcon.backgroundColorValue for palette colors 0-14, as the unsigned 32-bit
42
+ # value the plist stores. Names follow Shortcuts' picker order.
43
+ ICON_COLORS = {
44
+ "Red": 4282601983, "DarkOrange": 4251333119, "Orange": 4271458815, "Yellow": 4274264319,
45
+ "Green": 4292093695, "Teal": 431817727, "LightBlue": 1440408063, "Blue": 463140863,
46
+ "DarkBlue": 946986751, "Violet": 2071128575, "Purple": 3679049983, "Pink": 3980825855,
47
+ "Taupe": 255, "Gray": 3031607807, "DarkGray": 2846468607,
48
+ }
49
+ DEFAULT_GLYPH = 61440 # +[WFWorkflowIcon defaultGlyphCharacter]
50
+
51
+ # Legacy WFCondition codes for is.workflow.actions.conditional. Shortcuts still reads these
52
+ # and migrates them to the modern WFConditions template on load. Codes marked (v) were
53
+ # verified against shortcuts on this Mac or Apple's gallery; the rest follow the same
54
+ # enumeration as documented by the community (shortcuts-js, Cherri).
55
+ CONDITION = {
56
+ "less_than": 0, # (v) numbers
57
+ "less_or_equal": 1,
58
+ "greater_than": 2, # (v) numbers
59
+ "greater_or_equal": 3,
60
+ "is": 4,
61
+ "is_not": 5,
62
+ "begins_with": 8,
63
+ "ends_with": 9,
64
+ "contains": 99, # (v) as filter Operator in the gallery
65
+ "has_any_value": 100, # (v) supportedComparisonOperators of every subject state
66
+ "has_no_value": 101, # (v)
67
+ "does_not_contain": 999,
68
+ "is_between": 1003,
69
+ }
70
+
71
+
72
+ # Keys Shortcuts still accepts on load even though the current definition no longer lists
73
+ # them. Legacy If: single condition on WFInput, migrated to WFConditions by the app.
74
+ LEGACY_KEYS = {
75
+ "is.workflow.actions.conditional": {"WFInput", "WFCondition", "WFConditionalActionString", "WFNumberValue",
76
+ "WFConditionalLegacyComparisonBehavior", "WFEnumerationValue", "WFBooleanValue",
77
+ "WFDate", "WFAnotherDate", "WFDuration", "WFConditions"},
78
+ "is.workflow.actions.choosefrommenu": {"WFMenuPrompt", "WFMenuItems", "WFMenuItemTitle", "WFMenuItemAttributedTitle"},
79
+ }
80
+
81
+
82
+ def _is_reference(value):
83
+ """Attachments, token strings and picker wrappers are accepted wherever a plain value is."""
84
+ return isinstance(value, dict) and ("WFSerializationType" in value or value.get("Type") == "Variable")
85
+
86
+
87
+ def _check_kind(kind, value):
88
+ """Run-time counterpart of the TypeScript ParamTypes: returns a problem string or None."""
89
+ if _is_reference(value):
90
+ return None
91
+ if kind == "bool" and not isinstance(value, bool):
92
+ return f"expected a bool or a reference, got {type(value).__name__}"
93
+ if kind in ("number", "plainNumber") and (isinstance(value, bool) or not isinstance(value, (int, float))):
94
+ return f"expected a number{'' if kind == 'plainNumber' else ' or a reference'}, got {type(value).__name__}"
95
+ if kind in ("string", "text", "plainString") and not isinstance(value, str):
96
+ return f"expected a string{'' if kind == 'plainString' else ' or a reference'}, got {type(value).__name__}"
97
+ if kind == "picker":
98
+ return "expected picker(<attachment>) or an attachment"
99
+ return None
100
+
101
+
102
+ def _attachment(value):
103
+ return {"WFSerializationType": "WFTextTokenAttachment", "Value": value}
104
+
105
+
106
+ def ref(action, output_name=None):
107
+ """Reference another action's output. `action` is the dict returned by Shortcut.action()."""
108
+ return _attachment({"Type": "ActionOutput", "OutputUUID": action["WFWorkflowActionParameters"]["UUID"],
109
+ "OutputName": output_name or action.get("_outputName", "Output")})
110
+
111
+
112
+ def variable(name):
113
+ return _attachment({"Type": "Variable", "VariableName": name})
114
+
115
+
116
+ def shortcut_input():
117
+ return _attachment({"Type": "ExtensionInput"})
118
+
119
+
120
+ def clipboard():
121
+ return _attachment({"Type": "Clipboard"})
122
+
123
+
124
+ def current_date():
125
+ return _attachment({"Type": "CurrentDate"})
126
+
127
+
128
+ def ask(prompt=None):
129
+ return _attachment({"Type": "Ask", **({"Prompt": prompt} if prompt else {})})
130
+
131
+
132
+ def picker(attachment):
133
+ """Wrap an attachment for WFVariablePickerParameter keys (e.g. WFInput on Repeat with Each)."""
134
+ return {"Type": "Variable", "Variable": attachment}
135
+
136
+
137
+ def text(*parts):
138
+ """A WFTextTokenString: strings and attachments interleaved. Each attachment becomes U+FFFC."""
139
+ string = ""
140
+ attachments = {}
141
+ for part in parts:
142
+ if isinstance(part, dict) and part.get("WFSerializationType") == "WFTextTokenAttachment":
143
+ # NSRange offsets are UTF-16 code units, not code points.
144
+ attachments[f"{{{len(string.encode('utf-16-le')) // 2}, 1}}"] = part["Value"]
145
+ string += ""
146
+ else:
147
+ string += str(part)
148
+ return {"WFSerializationType": "WFTextTokenString", "Value": {"string": string, "attachmentsByRange": attachments}}
149
+
150
+
151
+ class Shortcut:
152
+ def __init__(self, name, color="Blue", glyph=DEFAULT_GLYPH, input_classes=None, definitions=DEFINITIONS):
153
+ self.name = name
154
+ self.color = ICON_COLORS[color] if isinstance(color, str) else color
155
+ self.glyph = glyph
156
+ self.input_classes = input_classes or []
157
+ self.actions = []
158
+ self.defs = json.load(open(definitions)) if pathlib.Path(definitions).exists() else {}
159
+
160
+ def action(self, identifier, **params):
161
+ """Append an action. Unknown identifiers or parameter keys raise ValueError."""
162
+ definition = self.defs.get(identifier)
163
+ if self.defs and definition is None and identifier.startswith("is.workflow."):
164
+ raise ValueError(f"unknown built-in action {identifier}")
165
+ if definition:
166
+ known = {p.get("Key") for p in definition.get("Parameters", []) if isinstance(p, dict)}
167
+ known |= {"UUID", "GroupingIdentifier", "WFControlFlowMode", "CustomOutputName"}
168
+ known |= LEGACY_KEYS.get(identifier, set())
169
+ inp = definition.get("Input")
170
+ if isinstance(inp, dict) and inp.get("ParameterKey"):
171
+ known.add(inp["ParameterKey"])
172
+ unknown = set(params) - known
173
+ if unknown and known - {"UUID", "GroupingIdentifier", "WFControlFlowMode", "CustomOutputName"}:
174
+ raise ValueError(f"{identifier}: unknown parameter(s) {sorted(unknown)}; known: {sorted(known)}")
175
+ for key, value in params.items():
176
+ problem = _check_kind(PARAM_KINDS.get(identifier, {}).get(key, "any"), value)
177
+ if problem:
178
+ raise ValueError(f"{identifier}.{key}: {problem}")
179
+ entry = {"WFWorkflowActionIdentifier": identifier,
180
+ "WFWorkflowActionParameters": {"UUID": str(uuid.uuid4()).upper(), **params}}
181
+ output = (definition or {}).get("Output", {})
182
+ entry["_outputName"] = output.get("OutputName") if isinstance(output, dict) else None
183
+ if isinstance(entry["_outputName"], dict):
184
+ entry["_outputName"] = entry["_outputName"].get("format")
185
+ self.actions.append(entry)
186
+ return entry
187
+
188
+ # Control flow: each block is a group sharing a GroupingIdentifier, with
189
+ # WFControlFlowMode 0 = start, 1 = middle (Otherwise / menu item), 2 = end.
190
+ def if_(self, subject, condition, value=None):
191
+ gid = str(uuid.uuid4()).upper()
192
+ params = {"GroupingIdentifier": gid, "WFControlFlowMode": 0, "WFInput": picker(subject),
193
+ "WFCondition": CONDITION[condition] if isinstance(condition, str) else condition}
194
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
195
+ params["WFNumberValue"] = value
196
+ elif value is not None:
197
+ params["WFConditionalActionString"] = value
198
+ self.action("is.workflow.actions.conditional", **params)
199
+ return gid
200
+
201
+ def otherwise(self, gid):
202
+ self.action("is.workflow.actions.conditional", GroupingIdentifier=gid, WFControlFlowMode=1)
203
+
204
+ def end_if(self, gid):
205
+ self.action("is.workflow.actions.conditional", GroupingIdentifier=gid, WFControlFlowMode=2)
206
+
207
+ def repeat_each(self, items):
208
+ """Open a Repeat with Each block; returns the grouping identifier to pass to end_repeat_each()."""
209
+ gid = str(uuid.uuid4()).upper()
210
+ self.action("is.workflow.actions.repeat.each", GroupingIdentifier=gid, WFControlFlowMode=0, WFInput=picker(items))
211
+ return gid
212
+
213
+ def end_repeat_each(self, gid):
214
+ self.action("is.workflow.actions.repeat.each", GroupingIdentifier=gid, WFControlFlowMode=2)
215
+
216
+ def to_plist(self):
217
+ actions = []
218
+ for a in self.actions:
219
+ actions.append({k: v for k, v in a.items() if not k.startswith("_")})
220
+ return {
221
+ "WFWorkflowClientVersion": "4018.0.4",
222
+ "WFWorkflowMinimumClientVersion": 900,
223
+ "WFWorkflowMinimumClientVersionString": "900",
224
+ "WFWorkflowIcon": {"WFWorkflowIconStartColor": self.color, "WFWorkflowIconGlyphNumber": self.glyph},
225
+ "WFWorkflowTypes": [],
226
+ "WFQuickActionSurfaces": [],
227
+ "WFWorkflowInputContentItemClasses": self.input_classes,
228
+ "WFWorkflowOutputContentItemClasses": [],
229
+ "WFWorkflowImportQuestions": [],
230
+ "WFWorkflowHasShortcutInputVariables": any(
231
+ json.dumps(a).find('"ExtensionInput"') >= 0 for a in actions),
232
+ "WFWorkflowHasOutputFallback": False,
233
+ "WFWorkflowActions": actions,
234
+ }
235
+
236
+ def write(self, path=None):
237
+ """Write the unsigned plist. Shortcuts names an imported shortcut after its file, so the
238
+ default file name is the shortcut's name."""
239
+ path = pathlib.Path(path) if path else pathlib.Path(f"{self.name}.shortcut")
240
+ with open(path, "wb") as f:
241
+ plistlib.dump(self.to_plist(), f, fmt=plistlib.FMT_BINARY)
242
+ return path
243
+
244
+ @staticmethod
245
+ def sign(src, dst, mode="anyone"):
246
+ subprocess.run(["shortcuts", "sign", "--mode", mode, "--input", str(src), "--output", str(dst)], check=True)
247
+ return dst
248
+
249
+
250
+ def demo(out):
251
+ out = pathlib.Path(out)
252
+ s = Shortcut(out.stem, color="Teal")
253
+ stored = s.action("is.workflow.actions.setstoredcontent", WFInput="hello from python",
254
+ WFStoredContentKey="demo-greeting", WFStoredContentGlobalValue=False)
255
+ got = s.action("is.workflow.actions.getstoredcontent", WFStoredContentKey="demo-greeting")
256
+ gid = s.if_(ref(got), "has_any_value")
257
+ s.action("is.workflow.actions.showresult", Text=text("Stored value: ", ref(got)))
258
+ s.otherwise(gid)
259
+ s.action("is.workflow.actions.showresult", Text=text("Nothing stored"))
260
+ s.end_if(gid)
261
+ unsigned = pathlib.Path(out).with_suffix(".unsigned.shortcut")
262
+ s.write(unsigned)
263
+ s.sign(unsigned, out)
264
+ print(f"wrote {out} ({len(s.actions)} actions)")
265
+
266
+
267
+ from . import actions # noqa: E402 (generated identifier constants and ACTIONS metadata)
268
+ from .actions import ACTIONS, PARAM_KINDS # noqa: E402
269
+
270
+ PROVENANCE = json.load(open(HERE / "data" / "provenance.json")) if (HERE / "data" / "provenance.json").exists() else {}
271
+ """Which macOS and Shortcuts build the bundled data was extracted from."""
272
+
273
+ __all__ = ["actions", "ACTIONS", "PARAM_KINDS", "PROVENANCE", "Shortcut", "ref", "variable", "shortcut_input", "clipboard", "current_date", "ask", "picker", "text",
274
+ "ICON_COLORS", "CONDITION", "DEFAULT_GLYPH", "LEGACY_KEYS", "demo"]
275
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ import sys
2
+ from . import demo
3
+
4
+ if len(sys.argv) == 3 and sys.argv[1] == "demo":
5
+ demo(sys.argv[2])
6
+ else:
7
+ sys.exit("usage: python -m shortcutkit demo OUT.shortcut")