workpeg 0.97.2__tar.gz → 0.98.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.
Files changed (75) hide show
  1. {workpeg-0.97.2/src/workpeg.egg-info → workpeg-0.98.0}/PKG-INFO +1 -1
  2. {workpeg-0.97.2 → workpeg-0.98.0}/pyproject.toml +1 -1
  3. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/build.py +135 -9
  4. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/compiler/ast_parser.py +184 -33
  5. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/compiler/js_generator.py +3 -1
  6. workpeg-0.98.0/src/workpeg/ui/runtime/icons.css.gz +0 -0
  7. {workpeg-0.97.2 → workpeg-0.98.0/src/workpeg.egg-info}/PKG-INFO +1 -1
  8. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg.egg-info/SOURCES.txt +1 -0
  9. {workpeg-0.97.2 → workpeg-0.98.0}/LICENSE +0 -0
  10. {workpeg-0.97.2 → workpeg-0.98.0}/MANIFEST.in +0 -0
  11. {workpeg-0.97.2 → workpeg-0.98.0}/README.md +0 -0
  12. {workpeg-0.97.2 → workpeg-0.98.0}/setup.cfg +0 -0
  13. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/__init__.py +0 -0
  14. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/build.py +0 -0
  15. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/cdn.py +0 -0
  16. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/cli.py +0 -0
  17. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/config.py +0 -0
  18. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/context.py +0 -0
  19. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/create_new.py +0 -0
  20. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/run.py +0 -0
  21. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/runtime.py +0 -0
  22. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/submit.py +0 -0
  23. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/__init__.py +0 -0
  24. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/functions/Dockerfile +0 -0
  25. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/functions/LICENSE +0 -0
  26. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/functions/README.md +0 -0
  27. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/functions/app/__init__.py +0 -0
  28. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/functions/app/main.py +0 -0
  29. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/templates/functions/requirements.txt +0 -0
  30. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/__init__.py +0 -0
  31. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/actions.py +0 -0
  32. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/app.py +0 -0
  33. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/compiler/__init__.py +0 -0
  34. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/compiler/ir.py +0 -0
  35. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/create.py +0 -0
  36. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/fake_cloud.py +0 -0
  37. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/minify.py +0 -0
  38. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/runtime/ai.js +0 -0
  39. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/runtime/pegment.css +0 -0
  40. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/runtime/scaffold.js +0 -0
  41. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/runtime/workpeg-runtime.js +0 -0
  42. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/scaffold.py +0 -0
  43. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/serve.py +0 -0
  44. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/state.py +0 -0
  45. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/validators.py +0 -0
  46. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/watcher.py +0 -0
  47. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/__init__.py +0 -0
  48. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/base.py +0 -0
  49. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/canvas.py +0 -0
  50. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/display.py +0 -0
  51. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/forms.py +0 -0
  52. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/futures.py +0 -0
  53. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/gestures.py +0 -0
  54. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/interactive.py +0 -0
  55. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/ui/widgets/layout.py +0 -0
  56. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg/utils.py +0 -0
  57. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg.egg-info/dependency_links.txt +0 -0
  58. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg.egg-info/entry_points.txt +0 -0
  59. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg.egg-info/requires.txt +0 -0
  60. {workpeg-0.97.2 → workpeg-0.98.0}/src/workpeg.egg-info/top_level.txt +0 -0
  61. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_build.py +0 -0
  62. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_cdn.py +0 -0
  63. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_cdn_publish.py +0 -0
  64. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_changelog.py +0 -0
  65. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_changelog_guard.py +0 -0
  66. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_cli.py +0 -0
  67. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_config.py +0 -0
  68. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_context.py +0 -0
  69. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_create_new.py +0 -0
  70. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_release_script.py +0 -0
  71. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_run.py +0 -0
  72. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_runtime.py +0 -0
  73. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_submit.py +0 -0
  74. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_utils.py +0 -0
  75. {workpeg-0.97.2 → workpeg-0.98.0}/tests/test_watch_pipeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workpeg
3
- Version: 0.97.2
3
+ Version: 0.98.0
4
4
  Summary: Workpeg function runtime and SDK
5
5
  Author-email: Workpeg <support@workpeg.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "workpeg"
7
- version = "0.97.2"
7
+ version = "0.98.0"
8
8
  description = "Workpeg function runtime and SDK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import gzip
3
4
  import hashlib
4
5
  import html
6
+ import re
5
7
  import shutil
6
8
  from pathlib import Path
7
9
  from urllib.parse import urlparse
@@ -85,6 +87,112 @@ def _icons_version(bundled: bool) -> str:
85
87
  return "0"
86
88
 
87
89
 
90
+ #: A gzip of the icon source, packaged INTO the wheel (~1 MB vs the 26 MB raw,
91
+ #: which stays excluded). It lets a wheel-only production build subset icons.css
92
+ #: without carrying the full sheet on disk. Dev/editable installs read the raw
93
+ #: file directly; either way the build ships only the tiny per-app subset.
94
+ _ICONS_CSS_GZ = _ICONS_CSS.parent / "icons.css.gz"
95
+
96
+ #: (source identity) -> (base rules, {icon-name: its rules}), so a watch rebuild
97
+ #: parses the ~26 MB sheet once, not per save. And the runtime's own icon set.
98
+ _icon_rules_memo: "dict[tuple, tuple]" = {}
99
+ _runtime_base_memo: "set[str] | None" = None
100
+
101
+
102
+ def _icons_source_key() -> "tuple | None":
103
+ """A cheap identity for the icon source (stat only, no read) — the raw
104
+ checkout copy preferred, else the packaged gzip; None when neither exists."""
105
+ if _ICONS_CSS.is_file():
106
+ st = _ICONS_CSS.stat()
107
+ return ("raw", st.st_mtime_ns, st.st_size)
108
+ if _ICONS_CSS_GZ.is_file():
109
+ st = _ICONS_CSS_GZ.stat()
110
+ return ("gz", st.st_mtime_ns, st.st_size)
111
+ return None
112
+
113
+
114
+ def _read_icons_source() -> str:
115
+ """The full icon stylesheet text — the raw file, or the decompressed gzip."""
116
+ if _ICONS_CSS.is_file():
117
+ return _ICONS_CSS.read_text(encoding="utf-8")
118
+ return gzip.decompress(_ICONS_CSS_GZ.read_bytes()).decode("utf-8")
119
+
120
+
121
+ def _icon_rule_index() -> "tuple | None":
122
+ """Parse the icon source into ``(base_rules, {name: its_rules})`` once,
123
+ memoized by source identity. Each ``.ti-NAME`` rule (and its ``.ti-thin`` /
124
+ ``.ti-bolder`` weight variants) is filed under NAME; rules with no
125
+ icon-specific class (the base ``.ti``) are base. ``{`` / ``}`` appear only as
126
+ rule delimiters — the SVG data-URIs are percent-encoded — so a flat regex
127
+ split is safe. Returns None when no icon source is present."""
128
+ key = _icons_source_key()
129
+ if key is None:
130
+ return None
131
+ if key in _icon_rules_memo:
132
+ return _icon_rules_memo[key]
133
+ text = _read_icons_source()
134
+ base: "list[str]" = []
135
+ per: "dict[str, list[str]]" = {}
136
+ for m in re.finditer(r"([^{}]+)\{([^{}]*)\}", text):
137
+ rule = m.group(0)
138
+ names = [n for n in re.findall(r"\.ti-([a-z0-9-]+)", m.group(1))
139
+ if n not in ("thin", "bolder")]
140
+ if not names:
141
+ base.append(rule)
142
+ else:
143
+ for n in set(names):
144
+ per.setdefault(n, []).append(rule)
145
+ index = ("".join(base), {n: "".join(r) for n, r in per.items()})
146
+ _icon_rules_memo.clear() # one live entry is plenty
147
+ _icon_rules_memo[key] = index
148
+ return index
149
+
150
+
151
+ def _runtime_base_icons() -> "set[str]":
152
+ """Icons the runtime bundles render themselves (the Scaffold chrome, the AI
153
+ surface, Icon fallbacks) — scanned from the runtime JS so the base set stays
154
+ in sync automatically, and always kept in a subset so the chrome renders.
155
+ Over-matching is harmless: only names that exist in the icon source are
156
+ emitted when subsetting."""
157
+ global _runtime_base_memo
158
+ if _runtime_base_memo is not None:
159
+ return _runtime_base_memo
160
+ names: "set[str]" = set()
161
+ for p in (_RUNTIME_JS, _SCAFFOLD_JS, _AI_JS):
162
+ if not p.is_file():
163
+ continue
164
+ txt = p.read_text(encoding="utf-8")
165
+ # The chrome names icons in many forms — icon("x"), icon: "x",
166
+ # topAction("edit"), iconBtn("refresh"), ti-history — so collect EVERY
167
+ # icon-shaped quoted string (and the ti-<name> classes). Over-matching
168
+ # is harmless: only names present in the icon source are emitted when
169
+ # subsetting, so non-icon strings ("click", "flex", …) drop out.
170
+ names.update(re.findall(r"""["']([a-z][a-z0-9-]*)["']""", txt))
171
+ names.update(re.findall(r"\bti-([a-z0-9-]+)\b", txt))
172
+ names -= {"thin", "bolder", "filled"}
173
+ # Selected/active states concatenate a "-filled" suffix (ti- + base +
174
+ # "-filled"), e.g. the thumb-up/down feedback — include those variants too.
175
+ names |= {n + "-filled" for n in tuple(names)}
176
+ _runtime_base_memo = names
177
+ return names
178
+
179
+
180
+ def _subset_icons_css(keep: "set[str]") -> "str | None":
181
+ """The icon stylesheet trimmed to ``keep`` plus the runtime's own icons —
182
+ the base ``.ti`` rules followed by only the used icons' rules. None when no
183
+ icon source is available (the caller then links the CDN full set)."""
184
+ index = _icon_rule_index()
185
+ if index is None:
186
+ return None
187
+ base, per = index
188
+ parts = [base]
189
+ for name in sorted(keep | _runtime_base_icons()):
190
+ rules = per.get(name)
191
+ if rules:
192
+ parts.append(rules)
193
+ return "".join(parts)
194
+
195
+
88
196
  def build_ui(
89
197
  source_path: str,
90
198
  output_dir: str = "dist",
@@ -106,7 +214,8 @@ def build_ui(
106
214
  if public.is_dir():
107
215
  shutil.copytree(public, out, dirs_exist_ok=True)
108
216
 
109
- ir = ASTParser().parse_file(str(source))
217
+ parser = ASTParser()
218
+ ir = parser.parse_file(str(source))
110
219
  gen = JSGenerator()
111
220
  js = gen.generate(ir)
112
221
  runtime = _RUNTIME_JS.read_text(encoding="utf-8")
@@ -156,25 +265,42 @@ def build_ui(
156
265
  (out / "ai.js").write_text(ai_js, encoding="utf-8")
157
266
  if scaffold_js:
158
267
  (out / "scaffold.js").write_text(scaffold_js, encoding="utf-8")
268
+ # Icon stylesheet. When the app's icons are all static, subset the sheet to
269
+ # just those (+ the runtime's own icons) and ship the tiny result — a page
270
+ # then loads a few KB instead of the ~26 MB full set. Works from the raw
271
+ # file (dev) or the packaged gzip (wheel). Dynamic icon names, or no icon
272
+ # source at all, fall back to the CDN full set (a dev build with the raw
273
+ # file still bundles it locally so it works offline).
159
274
  icons_href = ICONS_CDN
160
- bundled_icons = bundle_icons and gen.uses_icons and _ICONS_CSS.is_file()
161
- if bundled_icons:
162
- # Dev builds ship the packaged stylesheet next to the app (refreshed
163
- # only when the package copy is newer, so watch rebuilds don't rewrite
164
- # ~27 MB per save) and link it locally — any static server works.
165
- # Installed wheels don't carry the file (kept out to stay small), so
166
- # they fall through to the CDN link above.
275
+ icons_ver = _icons_version(False) if gen.uses_icons else ""
276
+ if gen.uses_icons and not parser.dynamic_icons:
277
+ subset = _subset_icons_css(parser.icon_names)
278
+ if subset is not None:
279
+ (out / "icons.css").write_text(subset, encoding="utf-8")
280
+ icons_href = ICONS_HREF
281
+ icons_ver = hashlib.sha1(subset.encode("utf-8")).hexdigest()[:8]
282
+ elif bundle_icons and gen.uses_icons and _ICONS_CSS.is_file():
283
+ # Dynamic icon names in a dev build: can't subset, so bundle the full
284
+ # local sheet (refreshed only when newer, so a watch rebuild doesn't
285
+ # rewrite ~26 MB per save). Production links the CDN copy above.
167
286
  icons_href = ICONS_HREF
168
287
  target = out / "icons.css"
288
+ # Size-aware, not mtime-only: a prior build may have left a KB-sized
289
+ # SUBSET here (static → then edited to a dynamic icon), whose mtime is
290
+ # newer than the source — a size mismatch forces the full sheet back in.
291
+ # The full → full fast path (equal size, copy2-preserved mtime) still
292
+ # skips the 26 MB rewrite on a watch save.
169
293
  if (not target.exists()
294
+ or target.stat().st_size != _ICONS_CSS.stat().st_size
170
295
  or target.stat().st_mtime < _ICONS_CSS.stat().st_mtime):
171
296
  shutil.copy2(_ICONS_CSS, target)
297
+ icons_ver = _icons_version(True)
172
298
  (out / "index.html").write_text(
173
299
  _gen_html(
174
300
  ir.name, ns, ver,
175
301
  uses_icons=gen.uses_icons,
176
302
  icons_href=icons_href,
177
- icons_ver=_icons_version(bundled_icons) if gen.uses_icons else "",
303
+ icons_ver=icons_ver,
178
304
  fonts=getattr(ir, "fonts", None),
179
305
  ),
180
306
  encoding="utf-8",
@@ -84,6 +84,12 @@ _WORKPEG_SESSION_HELPERS = {
84
84
  }
85
85
 
86
86
 
87
+ def _is_str_const(node: "ast.AST | None") -> bool:
88
+ """True when *node* is a string literal (an ``ast.Constant`` holding a str)
89
+ — used by the icon-literal scan to pick out ``icon="…"`` values."""
90
+ return isinstance(node, ast.Constant) and isinstance(node.value, str)
91
+
92
+
87
93
  def _is_workpeg_host(url: str) -> bool:
88
94
  """True when *url* targets a Workpeg-owned host — ``workpeg`` as the
89
95
  REGISTRABLE label, the one right before the TLD (workpeg.com, *.workpeg.io,
@@ -447,6 +453,11 @@ class ASTParser:
447
453
  _current_props: frozenset = frozenset()
448
454
  _current_methods: frozenset = frozenset()
449
455
  _root_base_dir: "str | None" = None
456
+ #: Comprehension loop vars in scope that iterate a STATICALLY known set — a
457
+ #: list/tuple literal, or a component prop fed literals at call sites. An
458
+ #: icon named through one is collectable (not runtime-dynamic); a loop var
459
+ #: over State / a call is NOT here, so the build keeps the full sheet.
460
+ _safe_loop_vars: frozenset = frozenset()
450
461
 
451
462
  def __init__(self) -> None:
452
463
  self._components: dict[str, "ComponentIR | None"] = {}
@@ -483,6 +494,15 @@ class ASTParser:
483
494
  # instead of "<unknown>".
484
495
  tree = ast.parse(source, filename=filename or "<unknown>")
485
496
 
497
+ # Icon subsetting: the set of literal icon names this build renders (so
498
+ # build.py can trim icons.css to just those), and whether any icon name
499
+ # is state-derived (then the full sheet is kept). Reset per parse and
500
+ # accumulated across the entry file + every imported/page module below.
501
+ self.icon_names = set()
502
+ self.dynamic_icons = False
503
+ self._safe_loop_vars = frozenset()
504
+ self._scan_icon_literals(tree)
505
+
486
506
  # Collect every class definition so Route entries can cross-reference them.
487
507
  class_defs: dict[str, ast.ClassDef] = {}
488
508
  for node in ast.walk(tree):
@@ -995,6 +1015,7 @@ class ASTParser:
995
1015
  ) -> "tuple[dict[str, ast.ClassDef], dict[str, str], dict[str, Any]]":
996
1016
  with open(file_path) as f:
997
1017
  tree = ast.parse(f.read(), filename=_display_path(file_path))
1018
+ self._scan_icon_literals(tree) # icon literals in imported modules
998
1019
  cdefs = {
999
1020
  n.name: n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)
1000
1021
  }
@@ -1013,6 +1034,7 @@ class ASTParser:
1013
1034
  extra: list[Any] = [] # positional args beyond the named props
1014
1035
  for i, a in enumerate(node.args):
1015
1036
  if i < len(comp.props):
1037
+ self._note_component_icon_prop(comp.props[i], a, scope)
1016
1038
  args[comp.props[i]] = self._parse_prop_value(a, scope)
1017
1039
  else:
1018
1040
  extra.append(self._parse_prop_value(a, scope))
@@ -1039,6 +1061,7 @@ class ASTParser:
1039
1061
  raise UnsupportedSyntaxError(
1040
1062
  f"{comp.name}() has no prop '{kw.arg}'"
1041
1063
  )
1064
+ self._note_component_icon_prop(kw.arg, kw.value, scope)
1042
1065
  args[kw.arg] = self._parse_prop_value(kw.value, scope)
1043
1066
  # Fill omitted parameters from their literal __init__ defaults, the
1044
1067
  # way Python would — so NavItem(label, href) really gets icon="circle".
@@ -2211,7 +2234,7 @@ class ASTParser:
2211
2234
  raise UnsupportedSyntaxError(
2212
2235
  "a canvas comprehension supports a single 'for' clause"
2213
2236
  )
2214
- params, inner, iterable = self._parse_comp_target(gens[0], scope)
2237
+ params, inner, iterable, _ = self._parse_comp_target(gens[0], scope)
2215
2238
  cond = self._and_conditions(gens[0].ifs, inner) if gens[0].ifs else None
2216
2239
  elt = self._parse_canvas_op(comp.elt, inner)
2217
2240
  return WidgetIR(type="__each", props={
@@ -2684,6 +2707,7 @@ class ASTParser:
2684
2707
  source = f.read()
2685
2708
  try:
2686
2709
  tree = ast.parse(source, filename=display)
2710
+ self._scan_icon_literals(tree) # icon literals in the page module
2687
2711
  class_defs: dict[str, ast.ClassDef] = {}
2688
2712
  for node in ast.walk(tree):
2689
2713
  if isinstance(node, ast.ClassDef):
@@ -3105,7 +3129,7 @@ class ASTParser:
3105
3129
  fake = ast.comprehension(
3106
3130
  target=stmt.target, iter=stmt.iter, ifs=[], is_async=0
3107
3131
  )
3108
- params, inner_scope, iterable = self._parse_comp_target(fake, scope)
3132
+ params, inner_scope, iterable, _ = self._parse_comp_target(fake, scope)
3109
3133
 
3110
3134
  body_locals = set(local_vars) | set(params)
3111
3135
  body = self._parse_block(stmt.body, body_locals, method_name)
@@ -4461,7 +4485,7 @@ class ASTParser:
4461
4485
  f"{fn}() supports a single 'for' clause"
4462
4486
  )
4463
4487
  comp = gen.generators[0]
4464
- params, inner_scope, iterable = self._parse_comp_target(comp, scope)
4488
+ params, inner_scope, iterable, _ = self._parse_comp_target(comp, scope)
4465
4489
  if comp.ifs:
4466
4490
  cond = self._and_conditions(comp.ifs, inner_scope)
4467
4491
  iterable = {"expr": "method", "target": iterable, "name": "filter",
@@ -4570,34 +4594,43 @@ class ASTParser:
4570
4594
  self, elt: ast.expr, gens: list, i: int, scope: frozenset
4571
4595
  ) -> Any:
4572
4596
  gen = gens[i]
4573
- params, inner_scope, base = self._parse_comp_target(gen, scope)
4597
+ params, inner_scope, base, safe = self._parse_comp_target(gen, scope)
4598
+ # The loop var is safe-static (its icons are collectable) only when the
4599
+ # iterable is a literal/prop; else it shadows any outer safe var. The
4600
+ # iterable (base) was parsed above in the OUTER scope; the ifs and body
4601
+ # below run with the loop var's safety in effect.
4602
+ saved_safe = self._safe_loop_vars
4603
+ self._safe_loop_vars = ((saved_safe | set(params)) if safe
4604
+ else (saved_safe - set(params)))
4605
+ try:
4606
+ if gen.ifs:
4607
+ cond = self._and_conditions(gen.ifs, inner_scope)
4608
+ base = {
4609
+ "expr": "method", "target": base, "name": "filter",
4610
+ "args": [{"expr": "lambda", "params": params, "body": cond}],
4611
+ }
4574
4612
 
4575
- if gen.ifs:
4576
- cond = self._and_conditions(gen.ifs, inner_scope)
4577
- base = {
4578
- "expr": "method", "target": base, "name": "filter",
4579
- "args": [{"expr": "lambda", "params": params, "body": cond}],
4580
- }
4613
+ if i + 1 < len(gens):
4614
+ inner = self._build_comprehension(elt, gens, i + 1, inner_scope)
4615
+ return {
4616
+ "expr": "method", "target": base, "name": "flatMap",
4617
+ "args": [{"expr": "lambda", "params": params, "body": inner}],
4618
+ }
4581
4619
 
4582
- if i + 1 < len(gens):
4583
- inner = self._build_comprehension(elt, gens, i + 1, inner_scope)
4620
+ # Leaf clause. Skip an identity map: [x for x in it] → it[.filter].
4621
+ if (
4622
+ len(params) == 1
4623
+ and isinstance(elt, ast.Name)
4624
+ and elt.id == params[0]
4625
+ ):
4626
+ return base
4627
+ body = self._parse_expr(elt, inner_scope)
4584
4628
  return {
4585
- "expr": "method", "target": base, "name": "flatMap",
4586
- "args": [{"expr": "lambda", "params": params, "body": inner}],
4629
+ "expr": "method", "target": base, "name": "map",
4630
+ "args": [{"expr": "lambda", "params": params, "body": body}],
4587
4631
  }
4588
-
4589
- # Leaf clause. Skip an identity map: [x for x in it] → it[.filter].
4590
- if (
4591
- len(params) == 1
4592
- and isinstance(elt, ast.Name)
4593
- and elt.id == params[0]
4594
- ):
4595
- return base
4596
- body = self._parse_expr(elt, inner_scope)
4597
- return {
4598
- "expr": "method", "target": base, "name": "map",
4599
- "args": [{"expr": "lambda", "params": params, "body": body}],
4600
- }
4632
+ finally:
4633
+ self._safe_loop_vars = saved_safe
4601
4634
 
4602
4635
  def _parse_widget_each(self, comp: ast.expr, scope: frozenset) -> WidgetIR:
4603
4636
  """Lower a spread widget comprehension (``*[W(x) for x in it if c]``) to
@@ -4610,9 +4643,16 @@ class ASTParser:
4610
4643
  raise UnsupportedSyntaxError(
4611
4644
  "a widget comprehension supports a single 'for' clause"
4612
4645
  )
4613
- params, inner, iterable = self._parse_comp_target(gens[0], scope)
4614
- cond = self._and_conditions(gens[0].ifs, inner) if gens[0].ifs else None
4615
- elt = self._parse_widget(comp.elt, inner)
4646
+ params, inner, iterable, safe = self._parse_comp_target(gens[0], scope)
4647
+ saved_safe = self._safe_loop_vars
4648
+ self._safe_loop_vars = ((saved_safe | set(params)) if safe
4649
+ else (saved_safe - set(params)))
4650
+ try:
4651
+ cond = (self._and_conditions(gens[0].ifs, inner)
4652
+ if gens[0].ifs else None)
4653
+ elt = self._parse_widget(comp.elt, inner)
4654
+ finally:
4655
+ self._safe_loop_vars = saved_safe
4616
4656
  return WidgetIR(type="__each", props={
4617
4657
  "iter": iterable, "params": params, "cond": cond, "elt": elt,
4618
4658
  })
@@ -4646,14 +4686,16 @@ class ASTParser:
4646
4686
  val_name = target.elts[1].id
4647
4687
  iterable = self._parse_expr(it.args[0], scope)
4648
4688
  params = [val_name, idx_name] # JS forEach/map order: value, index
4649
- return params, scope | {idx_name, val_name}, iterable
4689
+ return (params, scope | {idx_name, val_name}, iterable,
4690
+ self._iterable_is_safe(it.args[0]))
4650
4691
 
4651
4692
  if not isinstance(target, ast.Name):
4652
4693
  raise UnsupportedSyntaxError(
4653
4694
  "comprehension target must be a name, or (i, x) with enumerate()"
4654
4695
  )
4655
4696
  iterable = self._parse_expr(it, scope)
4656
- return [target.id], scope | {target.id}, iterable
4697
+ return ([target.id], scope | {target.id}, iterable,
4698
+ self._iterable_is_safe(it))
4657
4699
 
4658
4700
  def _and_conditions(self, ifs: list, scope: frozenset) -> Any:
4659
4701
  parsed = [self._parse_expr(c, scope) for c in ifs]
@@ -5580,12 +5622,117 @@ class ASTParser:
5580
5622
  props["name"] = self._icon_name(name_node, scope)
5581
5623
  return props
5582
5624
 
5625
+ def _add_icon_literal(self, raw: str) -> None:
5626
+ """Record a literal icon name (its base, minus any ``:weight`` suffix)
5627
+ for the build's icon-subsetting."""
5628
+ base = str(raw).partition(":")[0]
5629
+ if re.fullmatch(r"[a-z0-9-]+", base):
5630
+ self.icon_names.add(base)
5631
+
5632
+ def _scan_icon_literals(self, tree: ast.AST) -> None:
5633
+ """Collect every LITERAL icon name in a source tree — ``icon="…"`` on any
5634
+ call (so a component's icon prop, e.g. ``NavItem(icon="home")``, is
5635
+ caught), ``Icon("…")`` / ``Icon(name="…")``, and ``"icon": "…"`` in dict
5636
+ literals (menu items). Over-collection is harmless: a name absent from
5637
+ icons.css is dropped when subsetting. Whether the FULL sheet is still
5638
+ needed (a state-fed name) is decided per site in ``_icon_name``."""
5639
+ for n in ast.walk(tree):
5640
+ if isinstance(n, ast.Call):
5641
+ fn = n.func
5642
+ fname = (fn.attr if isinstance(fn, ast.Attribute)
5643
+ else getattr(fn, "id", ""))
5644
+ for kw in n.keywords:
5645
+ if kw.arg == "icon" and _is_str_const(kw.value):
5646
+ self._add_icon_literal(kw.value.value)
5647
+ elif (kw.arg == "name" and fname == "Icon"
5648
+ and _is_str_const(kw.value)):
5649
+ self._add_icon_literal(kw.value.value)
5650
+ if fname == "Icon" and n.args and _is_str_const(n.args[0]):
5651
+ self._add_icon_literal(n.args[0].value)
5652
+ elif isinstance(n, ast.Dict):
5653
+ for k, v in zip(n.keys, n.values):
5654
+ if (_is_str_const(k) and k.value == "icon"
5655
+ and _is_str_const(v)):
5656
+ self._add_icon_literal(v.value)
5657
+ elif isinstance(n, (ast.List, ast.Tuple)):
5658
+ # Icon-shaped string elements — e.g. NavItem's actions=["pin",
5659
+ # "star"], iterated into per-item icons. Each literal is
5660
+ # collected (a mixed list still contributes its literals);
5661
+ # non-icon strings are dropped at subset time.
5662
+ for e in n.elts:
5663
+ if (_is_str_const(e)
5664
+ and re.fullmatch(r"[a-z0-9-]+",
5665
+ e.value.partition(":")[0])):
5666
+ self._add_icon_literal(e.value)
5667
+ elif isinstance(n, ast.FunctionDef):
5668
+ # A component's literal ``icon=`` default — ``def __init__(self,
5669
+ # …, icon="circle")`` — renders when the prop is omitted, so
5670
+ # collect it. Defaults align to the tail of the positional args;
5671
+ # keyword-only defaults pair 1:1 (None where absent).
5672
+ a = n.args
5673
+ pairs = list(zip(a.args[len(a.args) - len(a.defaults):],
5674
+ a.defaults))
5675
+ pairs += list(zip(a.kwonlyargs, a.kw_defaults))
5676
+ for param, default in pairs:
5677
+ if param.arg == "icon" and _is_str_const(default):
5678
+ self._add_icon_literal(default.value)
5679
+
5680
+ def _icon_expr_is_dynamic(self, node: ast.expr, scope: frozenset) -> bool:
5681
+ """Whether a NON-literal icon name can vary at runtime, so the full icon
5682
+ set must be kept. A component-prop reference — ``self.<prop>``, or a
5683
+ subscript/attribute rooted at one (the SDK NavItem's ``Icon(self.icon)``
5684
+ and ``Icon(self.item["icon"])``) — is NOT dynamic: its literal values are
5685
+ collected from the call sites. A state read (``self.<state>``), an
5686
+ f-string, a call or any other expression IS, so the build safely falls
5687
+ back to the CDN full set. Component props live in ``_current_props`` (the
5688
+ ``scope`` arg is the local/loop vars in effect)."""
5689
+ props = self._current_props
5690
+ n = node
5691
+ while isinstance(n, (ast.Subscript, ast.Attribute)):
5692
+ if (isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name)
5693
+ and n.value.id == "self"):
5694
+ return n.attr not in props # a prop → static; else state
5695
+ n = n.value
5696
+ if isinstance(n, ast.Name):
5697
+ # A bare local/loop variable (or its subscript/attribute) — e.g.
5698
+ # `for a in self.actions: Icon(a)`. Static ONLY when it iterates a
5699
+ # statically known set (a list literal, or a prop): those loop vars
5700
+ # are in _safe_loop_vars. A loop over State (or a call) is NOT, so
5701
+ # it's dynamic and the build keeps the full sheet.
5702
+ return n.id not in self._safe_loop_vars
5703
+ return True # f-string, call, etc. → dynamic
5704
+
5705
+ def _iterable_is_safe(self, node: ast.expr) -> bool:
5706
+ """A comprehension iterable whose per-item icon names are statically
5707
+ collectable: a list/tuple of string LITERALS (scanned), or a component
5708
+ prop (``self.<prop>``, fed literals at the call sites). A State read, a
5709
+ mixed list, a call (a fetch) or an unknown expression is NOT — a loop var
5710
+ over it is runtime-dynamic, so the build falls back to the full sheet."""
5711
+ if isinstance(node, (ast.List, ast.Tuple)):
5712
+ return all(_is_str_const(e) for e in node.elts)
5713
+ if (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name)
5714
+ and node.value.id == "self"):
5715
+ return node.attr in self._current_props
5716
+ return False
5717
+
5718
+ def _note_component_icon_prop(
5719
+ self, prop_name: str, value_node: ast.expr, scope: frozenset
5720
+ ) -> None:
5721
+ """A component instance's ``icon=`` prop. Literal values are already
5722
+ collected by the scan; a NON-literal that is state-derived (not a prop
5723
+ passthrough like ``NavItem(icon=self.icon)``) can't be known at build
5724
+ time, so keep the full icon sheet."""
5725
+ if (prop_name == "icon" and not _is_str_const(value_node)
5726
+ and self._icon_expr_is_dynamic(value_node, scope)):
5727
+ self.dynamic_icons = True
5728
+
5583
5729
  def _icon_name(self, node: ast.expr, scope: frozenset) -> Any:
5584
5730
  """Validate a Tabler icon name (the Icon widget's name, or a Button/Link
5585
5731
  ``icon=``). A string literal must be ``[a-z0-9-]`` (a clear error for
5586
5732
  typos); a dynamic value (state / f-string → a dict expression) is allowed
5587
5733
  and sanitised to the same charset by the runtime, so it can only ever
5588
- produce a safe class."""
5734
+ produce a safe class. Both feed the build's icon-subsetting: a literal is
5735
+ recorded, a state-fed name keeps the full sheet (see _icon_expr_is_dynamic)."""
5589
5736
  name = self._parse_text_value(node, scope)
5590
5737
  if isinstance(name, str):
5591
5738
  base, sep, suffix = name.partition(":")
@@ -5602,6 +5749,10 @@ class ASTParser:
5602
5749
  "with a stroke-weight suffix like 'arrow-left:2'; "
5603
5750
  f"got: {name!r}"
5604
5751
  )
5752
+ self.icon_names.add(base)
5753
+ elif isinstance(name, dict):
5754
+ if self._icon_expr_is_dynamic(node, scope):
5755
+ self.dynamic_icons = True
5605
5756
  if not isinstance(name, (str, dict)):
5606
5757
  raise UnsupportedSyntaxError(f"icon must be a string; got: {name!r}")
5607
5758
  return name
@@ -82,7 +82,9 @@ _LISTVIEW_PROPS = {
82
82
  class JSGenerator:
83
83
 
84
84
  #: Set True while generating if the app renders any ``Icon`` — the build
85
- #: reads it to link the icon stylesheet (from the CDN) only when it's needed.
85
+ #: reads it to link the icon stylesheet only when it's needed. WHICH icons
86
+ #: (for subsetting) and whether any is dynamic are collected by the parser
87
+ #: (ASTParser.icon_names / .dynamic_icons), which sees component icon props.
86
88
  uses_icons = False
87
89
 
88
90
  def generate(self, ir: "AppIR | RouterIR") -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workpeg
3
- Version: 0.97.2
3
+ Version: 0.98.0
4
4
  Summary: Workpeg function runtime and SDK
5
5
  Author-email: Workpeg <support@workpeg.com>
6
6
  License: MIT
@@ -43,6 +43,7 @@ src/workpeg/ui/compiler/ast_parser.py
43
43
  src/workpeg/ui/compiler/ir.py
44
44
  src/workpeg/ui/compiler/js_generator.py
45
45
  src/workpeg/ui/runtime/ai.js
46
+ src/workpeg/ui/runtime/icons.css.gz
46
47
  src/workpeg/ui/runtime/pegment.css
47
48
  src/workpeg/ui/runtime/scaffold.js
48
49
  src/workpeg/ui/runtime/workpeg-runtime.js
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes