cudag 0.3.10__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.
Files changed (69) hide show
  1. cudag/__init__.py +334 -0
  2. cudag/annotation/__init__.py +77 -0
  3. cudag/annotation/codegen.py +648 -0
  4. cudag/annotation/config.py +545 -0
  5. cudag/annotation/loader.py +342 -0
  6. cudag/annotation/scaffold.py +121 -0
  7. cudag/annotation/transcription.py +296 -0
  8. cudag/cli/__init__.py +5 -0
  9. cudag/cli/main.py +315 -0
  10. cudag/cli/new.py +873 -0
  11. cudag/core/__init__.py +364 -0
  12. cudag/core/button.py +137 -0
  13. cudag/core/canvas.py +222 -0
  14. cudag/core/config.py +70 -0
  15. cudag/core/coords.py +233 -0
  16. cudag/core/data_grid.py +804 -0
  17. cudag/core/dataset.py +678 -0
  18. cudag/core/distribution.py +136 -0
  19. cudag/core/drawing.py +75 -0
  20. cudag/core/fonts.py +156 -0
  21. cudag/core/generator.py +163 -0
  22. cudag/core/grid.py +367 -0
  23. cudag/core/grounding_task.py +247 -0
  24. cudag/core/icon.py +207 -0
  25. cudag/core/iconlist_task.py +301 -0
  26. cudag/core/models.py +1251 -0
  27. cudag/core/random.py +130 -0
  28. cudag/core/renderer.py +190 -0
  29. cudag/core/screen.py +402 -0
  30. cudag/core/scroll_task.py +254 -0
  31. cudag/core/scrollable_grid.py +447 -0
  32. cudag/core/state.py +110 -0
  33. cudag/core/task.py +293 -0
  34. cudag/core/taskbar.py +350 -0
  35. cudag/core/text.py +212 -0
  36. cudag/core/utils.py +82 -0
  37. cudag/data/surnames.txt +5000 -0
  38. cudag/modal_apps/__init__.py +4 -0
  39. cudag/modal_apps/archive.py +103 -0
  40. cudag/modal_apps/extract.py +138 -0
  41. cudag/modal_apps/preprocess.py +529 -0
  42. cudag/modal_apps/upload.py +317 -0
  43. cudag/prompts/SYSTEM_PROMPT.txt +104 -0
  44. cudag/prompts/__init__.py +33 -0
  45. cudag/prompts/system.py +43 -0
  46. cudag/prompts/tools.py +382 -0
  47. cudag/py.typed +0 -0
  48. cudag/schemas/filesystem.json +90 -0
  49. cudag/schemas/test_record.schema.json +113 -0
  50. cudag/schemas/train_record.schema.json +90 -0
  51. cudag/server/__init__.py +21 -0
  52. cudag/server/app.py +232 -0
  53. cudag/server/services/__init__.py +9 -0
  54. cudag/server/services/generator.py +128 -0
  55. cudag/templates/scripts/archive.sh +35 -0
  56. cudag/templates/scripts/build.sh +13 -0
  57. cudag/templates/scripts/extract.sh +54 -0
  58. cudag/templates/scripts/generate.sh +116 -0
  59. cudag/templates/scripts/pre-commit.sh +44 -0
  60. cudag/templates/scripts/preprocess.sh +46 -0
  61. cudag/templates/scripts/upload.sh +63 -0
  62. cudag/templates/scripts/verify.py +428 -0
  63. cudag/validation/__init__.py +35 -0
  64. cudag/validation/validate.py +508 -0
  65. cudag-0.3.10.dist-info/METADATA +570 -0
  66. cudag-0.3.10.dist-info/RECORD +69 -0
  67. cudag-0.3.10.dist-info/WHEEL +4 -0
  68. cudag-0.3.10.dist-info/entry_points.txt +2 -0
  69. cudag-0.3.10.dist-info/licenses/LICENSE +66 -0
@@ -0,0 +1,342 @@
1
+ # Copyright (c) 2025 Tylt LLC. All rights reserved.
2
+ # CONFIDENTIAL AND PROPRIETARY. Unauthorized use, copying, or distribution
3
+ # is strictly prohibited. For licensing inquiries: hello@claimhawk.app
4
+
5
+ """Annotation loader for parsing Annotator exports."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import zipfile
12
+ from dataclasses import dataclass, field
13
+ from io import BytesIO
14
+ from pathlib import Path
15
+ from typing import Any, BinaryIO
16
+
17
+
18
+ @dataclass
19
+ class ParsedElement:
20
+ """Parsed UI element from annotation."""
21
+
22
+ id: str
23
+ element_type: str
24
+ bounds: tuple[int, int, int, int] # x, y, width, height
25
+ label: str | None = None
26
+
27
+ # Grid properties
28
+ rows: int | None = None
29
+ cols: int | None = None
30
+ row_heights: list[float] | None = None
31
+ col_widths: list[float] | None = None
32
+
33
+ # Mask properties
34
+ mask: bool = True
35
+ mask_color: str | None = None
36
+
37
+ # Export properties
38
+ export_icon: bool = False
39
+
40
+ # Text properties
41
+ text: str | None = None
42
+ text_align: str | None = None
43
+
44
+ # Computed names for code generation
45
+ python_name: str = ""
46
+ region_type: str = ""
47
+
48
+ def __post_init__(self) -> None:
49
+ """Compute derived fields."""
50
+ if not self.python_name:
51
+ self.python_name = self._to_snake_case(self.label or self.id)
52
+ if not self.region_type:
53
+ self.region_type = self._map_region_type()
54
+
55
+ def _to_snake_case(self, name: str) -> str:
56
+ """Convert name to valid Python identifier."""
57
+ # Remove non-alphanumeric characters
58
+ clean = re.sub(r"[^a-zA-Z0-9]", "_", name)
59
+ # Convert camelCase to snake_case
60
+ snake = re.sub(r"([a-z])([A-Z])", r"\1_\2", clean).lower()
61
+ # Remove consecutive underscores
62
+ snake = re.sub(r"_+", "_", snake)
63
+ # Ensure it starts with a letter
64
+ if snake and snake[0].isdigit():
65
+ snake = "el_" + snake
66
+ return snake or "unnamed"
67
+
68
+ def _map_region_type(self) -> str:
69
+ """Map element type to CUDAG region type."""
70
+ type_mapping = {
71
+ "button": "button",
72
+ "link": "button",
73
+ "tab": "button",
74
+ "menuitem": "button",
75
+ "checkbox": "button",
76
+ "radio": "button",
77
+ "textinput": "region",
78
+ "dropdown": "dropdown",
79
+ "listbox": "dropdown",
80
+ "grid": "grid",
81
+ "icon": "grid",
82
+ "scrollbar": "scrollable",
83
+ "panel": "region",
84
+ "dialog": "region",
85
+ "toolbar": "region",
86
+ "menubar": "region",
87
+ "text": "region",
88
+ "mask": "region",
89
+ "image": "region",
90
+ }
91
+ return type_mapping.get(self.element_type, "region")
92
+
93
+
94
+ @dataclass
95
+ class ParsedTask:
96
+ """Parsed task from annotation."""
97
+
98
+ id: str
99
+ prompt: str
100
+ target_element_id: str | None = None
101
+ action: str = "left_click"
102
+ action_params: dict[str, Any] = field(default_factory=dict)
103
+ prior_states: list[dict[str, Any]] = field(default_factory=list)
104
+
105
+ # Computed names for code generation
106
+ class_name: str = ""
107
+ python_name: str = ""
108
+ task_type: str = ""
109
+
110
+ def __post_init__(self) -> None:
111
+ """Compute derived fields."""
112
+ if not self.python_name:
113
+ self.python_name = self._derive_python_name()
114
+ if not self.class_name:
115
+ self.class_name = self._derive_class_name()
116
+ if not self.task_type:
117
+ self.task_type = self._derive_task_type()
118
+
119
+ def _derive_python_name(self) -> str:
120
+ """Derive Python identifier from task."""
121
+ # Try to create name from action and target
122
+ base = f"{self.action}_{self.target_element_id or 'element'}"
123
+ # Clean up
124
+ clean = re.sub(r"[^a-zA-Z0-9]", "_", base).lower()
125
+ clean = re.sub(r"_+", "_", clean)
126
+ return clean or "task"
127
+
128
+ def _derive_class_name(self) -> str:
129
+ """Derive class name from task."""
130
+ # Convert python_name to PascalCase
131
+ parts = self.python_name.split("_")
132
+ pascal = "".join(p.capitalize() for p in parts if p)
133
+ return f"{pascal}Task"
134
+
135
+ def _derive_task_type(self) -> str:
136
+ """Derive task type identifier."""
137
+ return self.python_name.replace("_", "-")
138
+
139
+
140
+ @dataclass
141
+ class ParsedAnnotation:
142
+ """Fully parsed annotation data."""
143
+
144
+ screen_name: str
145
+ image_size: tuple[int, int]
146
+ elements: list[ParsedElement]
147
+ tasks: list[ParsedTask]
148
+ image_path: str = ""
149
+
150
+ def to_dict(self) -> dict[str, Any]:
151
+ """Convert back to annotation.json format."""
152
+ return {
153
+ "screenName": self.screen_name,
154
+ "imageSize": list(self.image_size),
155
+ "imagePath": self.image_path,
156
+ "elements": [self._element_to_dict(el) for el in self.elements],
157
+ "tasks": [self._task_to_dict(t) for t in self.tasks],
158
+ }
159
+
160
+ def _element_to_dict(self, el: ParsedElement) -> dict[str, Any]:
161
+ """Convert element back to dict format."""
162
+ result: dict[str, Any] = {
163
+ "id": el.id,
164
+ "type": el.element_type,
165
+ "bbox": {
166
+ "x": el.bounds[0],
167
+ "y": el.bounds[1],
168
+ "width": el.bounds[2],
169
+ "height": el.bounds[3],
170
+ },
171
+ }
172
+ if el.label:
173
+ result["text"] = el.label
174
+ if el.rows:
175
+ result["rows"] = el.rows
176
+ if el.cols:
177
+ result["cols"] = el.cols
178
+ if el.row_heights:
179
+ result["rowHeights"] = el.row_heights
180
+ if el.col_widths:
181
+ result["colWidths"] = el.col_widths
182
+ if el.mask_color:
183
+ result["maskColor"] = el.mask_color
184
+ if el.text_align:
185
+ result["textAlign"] = el.text_align
186
+ return result
187
+
188
+ def _task_to_dict(self, t: ParsedTask) -> dict[str, Any]:
189
+ """Convert task back to dict format."""
190
+ result: dict[str, Any] = {
191
+ "id": t.id,
192
+ "prompt": t.prompt,
193
+ "action": t.action,
194
+ }
195
+ if t.target_element_id:
196
+ result["targetElementId"] = t.target_element_id
197
+ if t.task_type:
198
+ result["taskType"] = t.task_type
199
+ if t.prior_states:
200
+ result["priorStates"] = t.prior_states
201
+ # Add action params
202
+ if t.action == "type" and "text" in t.action_params:
203
+ result["text"] = t.action_params["text"]
204
+ elif t.action == "key" and "keys" in t.action_params:
205
+ result["keys"] = t.action_params["keys"]
206
+ elif t.action == "scroll" and "pixels" in t.action_params:
207
+ result["scrollPixels"] = t.action_params["pixels"]
208
+ elif t.action == "wait" and "ms" in t.action_params:
209
+ result["waitMs"] = t.action_params["ms"]
210
+ return result
211
+
212
+
213
+ class AnnotationLoader:
214
+ """Load and parse annotation data from various sources."""
215
+
216
+ def load(self, path: Path | str | BinaryIO) -> ParsedAnnotation:
217
+ """Load annotation from a file, folder, or stream.
218
+
219
+ Args:
220
+ path: Path to annotation.json, annotation.zip, annotation folder,
221
+ or a file-like object
222
+
223
+ Returns:
224
+ Parsed annotation data
225
+ """
226
+ if isinstance(path, (str, Path)):
227
+ path = Path(path)
228
+ if path.is_dir():
229
+ return self._load_folder(path)
230
+ elif path.suffix == ".zip":
231
+ return self._load_zip(path)
232
+ else:
233
+ with open(path) as f:
234
+ data = json.load(f)
235
+ return self.parse_dict(data)
236
+ else:
237
+ # File-like object - assume ZIP
238
+ return self._load_zip_stream(path)
239
+
240
+ def _load_folder(self, path: Path) -> ParsedAnnotation:
241
+ """Load annotation from unpacked folder."""
242
+ annotation_file = path / "annotation.json"
243
+ if not annotation_file.exists():
244
+ raise FileNotFoundError(f"No annotation.json found in {path}")
245
+ with open(annotation_file) as f:
246
+ data = json.load(f)
247
+ return self.parse_dict(data)
248
+
249
+ def _load_zip(self, path: Path) -> ParsedAnnotation:
250
+ """Load annotation from ZIP file."""
251
+ with zipfile.ZipFile(path) as zf:
252
+ return self._parse_zip(zf)
253
+
254
+ def _load_zip_stream(self, stream: BinaryIO) -> ParsedAnnotation:
255
+ """Load annotation from ZIP stream."""
256
+ with zipfile.ZipFile(stream) as zf:
257
+ return self._parse_zip(zf)
258
+
259
+ def _parse_zip(self, zf: zipfile.ZipFile) -> ParsedAnnotation:
260
+ """Parse annotation from opened ZIP file."""
261
+ annotation_file = zf.read("annotation.json")
262
+ data = json.loads(annotation_file.decode("utf-8"))
263
+ return self.parse_dict(data)
264
+
265
+ def parse_dict(self, data: dict[str, Any]) -> ParsedAnnotation:
266
+ """Parse annotation from dictionary.
267
+
268
+ Args:
269
+ data: Raw annotation dictionary
270
+
271
+ Returns:
272
+ Parsed annotation data
273
+ """
274
+ elements = [self._parse_element(el) for el in data.get("elements", [])]
275
+ tasks = [self._parse_task(t, elements) for t in data.get("tasks", [])]
276
+
277
+ return ParsedAnnotation(
278
+ screen_name=self._sanitize_name(data.get("screenName", "untitled")),
279
+ image_size=tuple(data.get("imageSize", [1000, 1000])),
280
+ elements=elements,
281
+ tasks=tasks,
282
+ image_path=data.get("imagePath", ""),
283
+ )
284
+
285
+ def _parse_element(self, el: dict[str, Any]) -> ParsedElement:
286
+ """Parse a single element."""
287
+ bbox = el.get("bbox", {})
288
+ return ParsedElement(
289
+ id=el.get("id", ""),
290
+ element_type=el.get("type", "button"),
291
+ bounds=(
292
+ bbox.get("x", 0),
293
+ bbox.get("y", 0),
294
+ bbox.get("width", 0),
295
+ bbox.get("height", 0),
296
+ ),
297
+ label=el.get("text"),
298
+ rows=el.get("rows"),
299
+ cols=el.get("cols"),
300
+ row_heights=el.get("rowHeights"),
301
+ col_widths=el.get("colWidths"),
302
+ mask=el.get("mask", True),
303
+ mask_color=el.get("maskColor"),
304
+ export_icon=el.get("exportIcon", False),
305
+ text=el.get("text"),
306
+ text_align=el.get("textAlign"),
307
+ )
308
+
309
+ def _parse_task(
310
+ self, t: dict[str, Any], elements: list[ParsedElement]
311
+ ) -> ParsedTask:
312
+ """Parse a single task."""
313
+ action = t.get("action", "left_click")
314
+ action_params: dict[str, Any] = {}
315
+
316
+ # Extract action-specific parameters
317
+ if action == "type":
318
+ action_params["text"] = t.get("text", "")
319
+ elif action == "key":
320
+ action_params["keys"] = t.get("keys", [])
321
+ elif action == "scroll":
322
+ action_params["pixels"] = t.get("scrollPixels", 100)
323
+ elif action == "wait":
324
+ action_params["ms"] = t.get("waitMs", 1000)
325
+ elif action in ("drag_to", "move_to"):
326
+ action_params["end_x"] = t.get("endX", 0)
327
+ action_params["end_y"] = t.get("endY", 0)
328
+
329
+ return ParsedTask(
330
+ id=t.get("id", ""),
331
+ prompt=t.get("prompt", ""),
332
+ target_element_id=t.get("targetElementId"),
333
+ action=action,
334
+ action_params=action_params,
335
+ prior_states=t.get("priorStates", []),
336
+ )
337
+
338
+ def _sanitize_name(self, name: str) -> str:
339
+ """Sanitize a name for use as a Python identifier."""
340
+ clean = re.sub(r"[^a-zA-Z0-9]", "_", name).lower()
341
+ clean = re.sub(r"_+", "_", clean).strip("_")
342
+ return clean or "untitled"
@@ -0,0 +1,121 @@
1
+ # Copyright (c) 2025 Tylt LLC. All rights reserved.
2
+ # CONFIDENTIAL AND PROPRIETARY. Unauthorized use, copying, or distribution
3
+ # is strictly prohibited. For licensing inquiries: hello@claimhawk.app
4
+
5
+ """Generator scaffolding from parsed annotations."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from cudag.annotation.loader import ParsedAnnotation
13
+ from cudag.annotation.codegen import (
14
+ generate_screen_py,
15
+ generate_state_py,
16
+ generate_renderer_py,
17
+ generate_generator_py,
18
+ generate_task_py,
19
+ generate_tasks_init_py,
20
+ generate_config_yaml,
21
+ generate_pyproject_toml,
22
+ )
23
+
24
+
25
+ def scaffold_generator(
26
+ name: str,
27
+ annotation: ParsedAnnotation,
28
+ output_dir: Path,
29
+ original_image: bytes | None = None,
30
+ masked_image: bytes | None = None,
31
+ icons: dict[str, bytes] | None = None,
32
+ in_place: bool = False,
33
+ ) -> list[Path]:
34
+ """Scaffold a complete CUDAG generator project from annotation.
35
+
36
+ Uses annotation-driven architecture where annotation.json is loaded at
37
+ runtime via AnnotationConfig, enabling updates without regenerating code.
38
+
39
+ Args:
40
+ name: Project name (used for directory name)
41
+ annotation: Parsed annotation data
42
+ output_dir: Parent directory for the project
43
+ original_image: Original screenshot bytes
44
+ masked_image: Masked image bytes (with dynamic regions blanked)
45
+ icons: Map of icon names to image bytes (optional)
46
+ in_place: If True, write directly to output_dir without creating subdirectory
47
+
48
+ Returns:
49
+ List of created file paths
50
+ """
51
+ project_dir = output_dir if in_place else output_dir / name
52
+ project_dir.mkdir(parents=True, exist_ok=True)
53
+
54
+ created_files: list[Path] = []
55
+
56
+ # Create directory structure (annotation-driven layout)
57
+ (project_dir / "tasks").mkdir(exist_ok=True)
58
+ (project_dir / "assets" / "annotations").mkdir(parents=True, exist_ok=True)
59
+ (project_dir / "assets" / "icons").mkdir(exist_ok=True)
60
+ (project_dir / "config").mkdir(exist_ok=True)
61
+
62
+ # Save annotation.json (single source of truth)
63
+ annotation_json = project_dir / "assets" / "annotations" / "annotation.json"
64
+ annotation_json.write_text(json.dumps(annotation.to_dict(), indent=2))
65
+ created_files.append(annotation_json)
66
+
67
+ # Generate Python files
68
+ screen_py = project_dir / "screen.py"
69
+ screen_py.write_text(generate_screen_py(annotation))
70
+ created_files.append(screen_py)
71
+
72
+ state_py = project_dir / "state.py"
73
+ state_py.write_text(generate_state_py(annotation))
74
+ created_files.append(state_py)
75
+
76
+ renderer_py = project_dir / "renderer.py"
77
+ renderer_py.write_text(generate_renderer_py(annotation))
78
+ created_files.append(renderer_py)
79
+
80
+ generator_py = project_dir / "generator.py"
81
+ generator_py.write_text(generate_generator_py(annotation))
82
+ created_files.append(generator_py)
83
+
84
+ # Generate task files
85
+ tasks_init = project_dir / "tasks" / "__init__.py"
86
+ tasks_init.write_text(generate_tasks_init_py(annotation.tasks))
87
+ created_files.append(tasks_init)
88
+
89
+ for task in annotation.tasks:
90
+ task_file = project_dir / "tasks" / f"{task.python_name}.py"
91
+ task_file.write_text(generate_task_py(task, annotation))
92
+ created_files.append(task_file)
93
+
94
+ # Generate config
95
+ config_yaml = project_dir / "config" / "dataset.yaml"
96
+ config_yaml.write_text(generate_config_yaml(annotation))
97
+ created_files.append(config_yaml)
98
+
99
+ # Generate pyproject.toml
100
+ pyproject = project_dir / "pyproject.toml"
101
+ pyproject.write_text(generate_pyproject_toml(name))
102
+ created_files.append(pyproject)
103
+
104
+ # Save images to annotations directory
105
+ if original_image:
106
+ original_png = project_dir / "assets" / "annotations" / "original.png"
107
+ original_png.write_bytes(original_image)
108
+ created_files.append(original_png)
109
+
110
+ if masked_image:
111
+ masked_png = project_dir / "assets" / "annotations" / "masked.png"
112
+ masked_png.write_bytes(masked_image)
113
+ created_files.append(masked_png)
114
+
115
+ if icons:
116
+ for icon_name, icon_bytes in icons.items():
117
+ icon_path = project_dir / "assets" / "icons" / f"{icon_name}.png"
118
+ icon_path.write_bytes(icon_bytes)
119
+ created_files.append(icon_path)
120
+
121
+ return created_files