android-agent-harness 0.27.6__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 (76) hide show
  1. agents/mcp/zoho_sprints/README.md +26 -0
  2. agents/mcp/zoho_sprints/_client.py +304 -0
  3. agents/mcp/zoho_sprints/_config.py +105 -0
  4. agents/mcp/zoho_sprints/_dns.py +92 -0
  5. agents/mcp/zoho_sprints/_formatter.py +132 -0
  6. agents/mcp/zoho_sprints/server.py +353 -0
  7. agents/scripts/_adb_core.py +1346 -0
  8. agents/scripts/_adb_core_selftest.py +251 -0
  9. agents/scripts/_apk_freshness.py +272 -0
  10. agents/scripts/_apk_freshness_selftest.py +190 -0
  11. agents/scripts/_baseline_selftest.py +169 -0
  12. agents/scripts/_env_codes.py +288 -0
  13. agents/scripts/_env_codes_selftest.py +229 -0
  14. agents/scripts/_final_verdict_selftest.py +302 -0
  15. agents/scripts/_gate_results.py +85 -0
  16. agents/scripts/_graph_core.py +1308 -0
  17. agents/scripts/_graph_selftest.py +400 -0
  18. agents/scripts/_hook_selftest.py +2981 -0
  19. agents/scripts/_hook_state.py +837 -0
  20. agents/scripts/_live_process.py +130 -0
  21. agents/scripts/_modules.py +37 -0
  22. agents/scripts/_product.py +41 -0
  23. agents/scripts/_repo_files.py +291 -0
  24. agents/scripts/_risk_and_impact_selftest.py +325 -0
  25. agents/scripts/_round_cap_selftest.py +141 -0
  26. agents/scripts/_security_selftest.py +484 -0
  27. agents/scripts/_variants.py +96 -0
  28. agents/scripts/approve_risk.py +100 -0
  29. agents/scripts/baseline_capture.py +203 -0
  30. agents/scripts/capture_screen.py +103 -0
  31. agents/scripts/cc_pre_tool_safety.py +84 -0
  32. agents/scripts/check_kit_update.py +225 -0
  33. agents/scripts/check_strings.py +491 -0
  34. agents/scripts/copilot_pre_tool_safety.py +99 -0
  35. agents/scripts/doctor/__init__.py +20 -0
  36. agents/scripts/doctor/engine.py +829 -0
  37. agents/scripts/doctor/models.py +125 -0
  38. agents/scripts/ensure_hook_selftest.py +185 -0
  39. agents/scripts/fast_kt_lint.py +449 -0
  40. agents/scripts/final_verdict.py +365 -0
  41. agents/scripts/gradle_error_parser.py +88 -0
  42. agents/scripts/harness_doctor.py +113 -0
  43. agents/scripts/impact_analyzer.py +343 -0
  44. agents/scripts/install_or_update.py +667 -0
  45. agents/scripts/install_tool_adapters.py +788 -0
  46. agents/scripts/install_zoho_mcp.py +170 -0
  47. agents/scripts/logcat_doctor.py +147 -0
  48. agents/scripts/new_feature_scaffold.py +378 -0
  49. agents/scripts/perf_guard.py +178 -0
  50. agents/scripts/pm_github.py +256 -0
  51. agents/scripts/pm_policy.py +297 -0
  52. agents/scripts/policy_vocab.py +226 -0
  53. agents/scripts/pre_commit_gate.py +116 -0
  54. agents/scripts/pre_invocation_reminder.py +151 -0
  55. agents/scripts/pre_tool_safety.py +1078 -0
  56. agents/scripts/preflight_check.py +86 -0
  57. agents/scripts/project_graph.py +235 -0
  58. agents/scripts/review_package.py +302 -0
  59. agents/scripts/risk_tier.py +356 -0
  60. agents/scripts/room_guard.py +259 -0
  61. agents/scripts/run_device.py +193 -0
  62. agents/scripts/run_gradle_task.py +263 -0
  63. agents/scripts/run_tests_gate.py +141 -0
  64. agents/scripts/setup_wizard.py +217 -0
  65. agents/scripts/wizard/__init__.py +2 -0
  66. agents/scripts/wizard/discovery.py +536 -0
  67. agents/scripts/wizard/i18n.py +281 -0
  68. agents/scripts/wizard/questions.py +875 -0
  69. agents/tool-adapters/README.md +32 -0
  70. android_agent_harness-0.27.6.dist-info/METADATA +163 -0
  71. android_agent_harness-0.27.6.dist-info/RECORD +76 -0
  72. android_agent_harness-0.27.6.dist-info/WHEEL +5 -0
  73. android_agent_harness-0.27.6.dist-info/entry_points.txt +2 -0
  74. android_agent_harness-0.27.6.dist-info/licenses/LICENSE +21 -0
  75. android_agent_harness-0.27.6.dist-info/top_level.txt +2 -0
  76. harness_cli.py +760 -0
@@ -0,0 +1,26 @@
1
+ # Zoho Sprints MCP
2
+
3
+ Stdio MCP server. Same tools and mutate rules as the original engine (`update zoho`, Arabic templates, `In progress` / `Ready To ReTest`). Playbook: `../../workflows/zoho-sprints.md`.
4
+
5
+ The kit ships **code + empty workflow defaults**, not tokens.
6
+
7
+ Credentials stay on the developer's machine:
8
+
9
+ 1. Copy `config.example.json` to `~/.android-harness/zoho_sprints.json`
10
+ 2. Fill `client_id`, `client_secret`, `refresh_token`, `team_id`, `project_id`
11
+ 3. Never put that file in the app repo or in this kit
12
+
13
+ To get Zoho API credentials:
14
+
15
+ 1. Go to https://api-console.zoho.com/ and create a **Self Client**
16
+ 2. Generate a grant token with scope: `ZohoSprints.sprints.ALL,ZohoSprints.items.ALL,ZohoSprints.team.READ`
17
+ 3. Exchange the grant token for a refresh token using the Zoho OAuth endpoint
18
+ 4. Find your `team_id` and `project_id` from your Zoho Sprints URL: `https://sprints.zoho.com/team/<team_id>/project/<project_id>/...`
19
+
20
+ `workflow_defaults.json` has assignee / item-prefix / title-strip settings. Empty values are resolved at runtime by the server. Fill them during install (I.16) or configure later.
21
+
22
+ Setup (`I.16`) wires `.agents/mcp_config.json` (and Cursor `.cursor/mcp.json` when Cursor was selected) to this `server.py` and sets `ZOHO_SPRINTS_CONFIG` to an **existing** user file if one is already on the PC. It does not copy tokens.
23
+
24
+ If a config already exists at `~/.gemini/antigravity/scratch/zoho_sprints/zoho_config.json`, setup points at that path and leaves the file untouched.
25
+
26
+ Zoho Desk is not included.
@@ -0,0 +1,304 @@
1
+ """Zoho Sprints API client, OAuth authentication, caching and endpoints."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from _formatter import format_zoho_html
15
+
16
+ HERE = Path(__file__).resolve().parent
17
+
18
+ WORKFLOW_KEYS = (
19
+ "team_id",
20
+ "project_id",
21
+ "default_user_id",
22
+ "item_prefix",
23
+ "title_strip_suffixes",
24
+ "fallback_item_type_id",
25
+ "fallback_priority_id",
26
+ "fallback_sprint_id",
27
+ )
28
+
29
+
30
+ def _workflow_defaults() -> dict:
31
+ path = HERE / "workflow_defaults.json"
32
+ if not path.is_file():
33
+ return {}
34
+ data = json.loads(path.read_text(encoding="utf-8"))
35
+ return data if isinstance(data, dict) else {}
36
+
37
+
38
+ class ZohoSprintsAPI:
39
+ METADATA_CACHE_TTL = 300
40
+
41
+ def __init__(self, config_path: str):
42
+ self.config_path = config_path
43
+ self._meta_cache: dict[str, tuple[float, dict]] = {}
44
+ self.base_url = "https://sprintsapi.zoho.com/zsapi"
45
+ self.load_config()
46
+
47
+ def load_config(self) -> None:
48
+ with open(self.config_path, encoding="utf-8") as f:
49
+ user = json.load(f)
50
+ if not isinstance(user, dict):
51
+ raise RuntimeError("Zoho Sprints config must be a JSON object.")
52
+ defaults = _workflow_defaults()
53
+ self.config = dict(user)
54
+ for key in WORKFLOW_KEYS:
55
+ if key not in user or user.get(key) in (None, "", []):
56
+ if key in defaults:
57
+ self.config[key] = defaults[key]
58
+ self.access_token = user.get("access_token")
59
+ self.refresh_token = user.get("refresh_token")
60
+ self.client_id = user.get("client_id")
61
+ self.client_secret = user.get("client_secret")
62
+ self.auth_domain = user.get("auth_domain") or "https://accounts.zoho.com"
63
+ self.team_id = str(self.config.get("team_id") or "").strip()
64
+ self.project_id = str(self.config.get("project_id") or "").strip()
65
+ self.default_user_id = str(self.config.get("default_user_id") or "").strip()
66
+ self.item_prefix = str(self.config.get("item_prefix") or "").strip()
67
+ self.fallback_item_type_id = str(self.config.get("fallback_item_type_id") or "").strip()
68
+ self.fallback_priority_id = str(self.config.get("fallback_priority_id") or "").strip()
69
+ self.fallback_sprint_id = str(self.config.get("fallback_sprint_id") or "").strip()
70
+ suffixes = self.config.get("title_strip_suffixes") or []
71
+ self.title_strip_suffixes = [str(s) for s in suffixes if str(s).strip()]
72
+ if not self.team_id or not self.project_id:
73
+ raise RuntimeError("Zoho Sprints config needs team_id and project_id.")
74
+
75
+ def save_config(self) -> None:
76
+ path = Path(self.config_path)
77
+ path.parent.mkdir(parents=True, exist_ok=True)
78
+ try:
79
+ if hasattr(os, "O_CREAT") and os.name != "nt":
80
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
81
+ with open(fd, "w", encoding="utf-8") as f:
82
+ json.dump(self.config, f, indent=2)
83
+ else:
84
+ with open(self.config_path, "w", encoding="utf-8") as f:
85
+ json.dump(self.config, f, indent=2)
86
+ except Exception:
87
+ with open(self.config_path, "w", encoding="utf-8") as f:
88
+ json.dump(self.config, f, indent=2)
89
+
90
+ def sanitize_task_name(self, name: str) -> str:
91
+ cleaned = name.strip()
92
+ for suffix in self.title_strip_suffixes:
93
+ cleaned = re.sub(
94
+ r"\s*[-–—]\s*" + re.escape(suffix) + r"\s*$",
95
+ "",
96
+ cleaned,
97
+ flags=re.IGNORECASE,
98
+ ).strip()
99
+ return cleaned
100
+
101
+ def refresh_token_if_needed(self) -> None:
102
+ url = f"{self.auth_domain}/oauth/v2/token"
103
+ params = {
104
+ "refresh_token": self.refresh_token,
105
+ "client_id": self.client_id,
106
+ "client_secret": self.client_secret,
107
+ "grant_type": "refresh_token",
108
+ }
109
+ encoded = urllib.parse.urlencode(params).encode("utf-8")
110
+ req = urllib.request.Request(url, data=encoded, method="POST")
111
+ try:
112
+ with urllib.request.urlopen(req, timeout=30.0) as resp:
113
+ res = json.loads(resp.read().decode("utf-8"))
114
+ except Exception as e:
115
+ raise RuntimeError(f"Zoho OAuth token refresh network error: {e}") from e
116
+ if "access_token" in res:
117
+ self.access_token = res["access_token"]
118
+ self.config["access_token"] = self.access_token
119
+ self.save_config()
120
+ else:
121
+ err = res.get("error", "unknown_oauth_error")
122
+ raise RuntimeError(f"Zoho OAuth token refresh failed: {err}. Re-authenticate in config.")
123
+
124
+ def _request(self, path: str, method: str = "GET", params: dict | None = None, data: dict | None = None) -> Any:
125
+ url = f"{self.base_url}{path}"
126
+ if params:
127
+ url += "?" + urllib.parse.urlencode(params)
128
+ headers = {"Authorization": f"Zoho-oauthtoken {self.access_token}"}
129
+ encoded_data = None
130
+ if data is not None:
131
+ encoded_data = urllib.parse.urlencode(data).encode("utf-8")
132
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
133
+
134
+ max_retries = 3
135
+ for attempt in range(max_retries):
136
+ req = urllib.request.Request(url, data=encoded_data, headers=headers, method=method)
137
+ try:
138
+ with urllib.request.urlopen(req, timeout=30.0) as resp:
139
+ return json.loads(resp.read().decode("utf-8"))
140
+ except urllib.error.HTTPError as exc:
141
+ err_msg = exc.read().decode("utf-8", errors="replace")
142
+ if exc.code in (429, 502, 503, 504) and attempt < max_retries - 1:
143
+ time.sleep(1.0 * (attempt + 1))
144
+ continue
145
+ if exc.code == 401 or "Invalid oauthToken" in err_msg or "invalid_token" in err_msg:
146
+ self.refresh_token_if_needed()
147
+ headers["Authorization"] = f"Zoho-oauthtoken {self.access_token}"
148
+ req2 = urllib.request.Request(url, data=encoded_data, headers=headers, method=method)
149
+ try:
150
+ with urllib.request.urlopen(req2, timeout=30.0) as resp2:
151
+ return json.loads(resp2.read().decode("utf-8"))
152
+ except Exception as retry_exc:
153
+ raise RuntimeError(f"Zoho request failed after token refresh: {retry_exc}") from retry_exc
154
+ raise RuntimeError(f"HTTP Error {exc.code}: {err_msg}") from exc
155
+ except Exception as exc:
156
+ if attempt < max_retries - 1 and isinstance(exc, (urllib.error.URLError, TimeoutError)):
157
+ time.sleep(1.0 * (attempt + 1))
158
+ continue
159
+ raise RuntimeError(f"Zoho request failed ({method} {path}): {exc}") from exc
160
+
161
+ def get_sprints(self) -> dict:
162
+ return self._request(
163
+ f"/team/{self.team_id}/projects/{self.project_id}/sprints/",
164
+ params={"action": "data", "type": "[1,2,3,4]"},
165
+ )
166
+
167
+ def get_active_sprint_id(self) -> str:
168
+ ids = self.get_sprints().get("sprintIds") or []
169
+ if ids:
170
+ return ids[0]
171
+ if self.fallback_sprint_id:
172
+ return self.fallback_sprint_id
173
+ raise RuntimeError("No sprints found for this Zoho project.")
174
+
175
+ def _cached_request(self, cache_key: str, path: str, params: dict | None = None) -> dict:
176
+ cached = self._meta_cache.get(cache_key)
177
+ if cached:
178
+ ts, data = cached
179
+ if time.time() - ts < self.METADATA_CACHE_TTL:
180
+ return data
181
+ result = self._request(path, params=params)
182
+ self._meta_cache[cache_key] = (time.time(), result)
183
+ return result
184
+
185
+ def get_item_types(self) -> dict:
186
+ return self._cached_request(
187
+ "item_types",
188
+ f"/team/{self.team_id}/projects/{self.project_id}/itemtype/",
189
+ params={"action": "data"},
190
+ )
191
+
192
+ def get_statuses(self) -> dict:
193
+ return self._cached_request(
194
+ "statuses",
195
+ f"/team/{self.team_id}/projects/{self.project_id}/itemstatus/",
196
+ params={"action": "data"},
197
+ )
198
+
199
+ def get_priorities(self) -> dict:
200
+ return self._cached_request(
201
+ "priorities",
202
+ f"/team/{self.team_id}/projects/{self.project_id}/priority/",
203
+ params={"action": "data"},
204
+ )
205
+
206
+ def list_items(self, sprint_id: str) -> dict:
207
+ return self._request(
208
+ f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/",
209
+ params={"action": "sprintitems", "subitem": "true"},
210
+ )
211
+
212
+ def resolve_item(self, item_input: str, sprint_id: str | None = None) -> tuple[str, str]:
213
+ raw_input = str(item_input).strip()
214
+ match = re.search(r"(\d+)", raw_input)
215
+ target_num = match.group(1) if match else raw_input
216
+ sprint_ids = self.get_sprints().get("sprintIds") or []
217
+ if len(raw_input) >= 15 and raw_input.isdigit():
218
+ if sprint_id:
219
+ return sprint_id, raw_input
220
+ for sid in sprint_ids:
221
+ try:
222
+ res = self.get_item_details(sid, raw_input)
223
+ if res.get("status") == "success":
224
+ return sid, raw_input
225
+ except Exception:
226
+ continue
227
+ search_sprints = [sprint_id] if sprint_id else sprint_ids
228
+ prefix = self.item_prefix
229
+ for sid in search_sprints:
230
+ items_data = self.list_items(sid)
231
+ item_prop = items_data.get("item_prop") or {}
232
+ item_no_idx = item_prop.get("itemNo")
233
+ item_jobj = items_data.get("itemJObj") or {}
234
+ for iid, row in item_jobj.items():
235
+ if iid == raw_input:
236
+ return sid, iid
237
+ if item_no_idx is not None and len(row) > item_no_idx:
238
+ item_no = str(row[item_no_idx])
239
+ labels = {item_no, f"I{item_no}", target_num}
240
+ if prefix:
241
+ labels.add(f"{prefix}{item_no}")
242
+ if raw_input in labels or item_no == target_num:
243
+ return sid, iid
244
+ raise RuntimeError(
245
+ f"Item '{raw_input}' not found in {len(search_sprints)} sprint(s). "
246
+ "Verify the task id or pass sprint_id."
247
+ )
248
+
249
+ def get_item_details(self, sprint_id: str | None = None, item_id: str | None = None) -> dict:
250
+ if not sprint_id or (item_id and (not str(item_id).isdigit() or len(str(item_id)) < 15)):
251
+ sprint_id, item_id = self.resolve_item(item_id or sprint_id or "", sprint_id)
252
+ return self._request(
253
+ f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/{item_id}/",
254
+ params={"action": "details"},
255
+ )
256
+
257
+ def create_item(
258
+ self,
259
+ sprint_id: str,
260
+ name: str,
261
+ item_type_id: str,
262
+ priority_id: str,
263
+ description: str = "",
264
+ points: str = "0",
265
+ parent_item_id: str | None = None,
266
+ users: list | None = None,
267
+ ) -> dict:
268
+ data: dict[str, str] = {
269
+ "name": self.sanitize_task_name(name),
270
+ "projitemtypeid": item_type_id,
271
+ "projpriorityid": priority_id,
272
+ "description": format_zoho_html(description),
273
+ }
274
+ assigned = users if users is not None else ([self.default_user_id] if self.default_user_id else None)
275
+ if assigned:
276
+ data["users"] = json.dumps(assigned)
277
+ if points and points != "0":
278
+ data["points"] = points
279
+ if parent_item_id:
280
+ path = f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/{parent_item_id}/subitem/"
281
+ else:
282
+ path = f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/"
283
+ return self._request(path, method="POST", data=data)
284
+
285
+ def update_item_status(self, sprint_id: str, item_id: str, status_id: str) -> dict:
286
+ return self._request(
287
+ f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/{item_id}/",
288
+ method="POST",
289
+ data={"statusid": status_id},
290
+ )
291
+
292
+ def update_item_description(self, sprint_id: str, item_id: str, description: str) -> dict:
293
+ return self._request(
294
+ f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/{item_id}/",
295
+ method="POST",
296
+ data={"description": format_zoho_html(description)},
297
+ )
298
+
299
+ def add_comment(self, sprint_id: str, item_id: str, content: str) -> dict:
300
+ return self._request(
301
+ f"/team/{self.team_id}/projects/{self.project_id}/sprints/{sprint_id}/item/{item_id}/notes/",
302
+ method="POST",
303
+ data={"name": format_zoho_html(content)},
304
+ )
@@ -0,0 +1,105 @@
1
+ """Locate the user-level Zoho Sprints config. Never copy or print secrets."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+
7
+ SECRET_KEYS = frozenset(
8
+ {
9
+ "access_token",
10
+ "refresh_token",
11
+ "client_secret",
12
+ "client_id",
13
+ "api_key",
14
+ "token",
15
+ "password",
16
+ }
17
+ )
18
+
19
+ ENV_CONFIG = "ZOHO_SPRINTS_CONFIG"
20
+ SERVER_NAME = "zoho-sprints"
21
+
22
+
23
+ def home_config_path() -> Path:
24
+ return Path.home() / ".android-harness" / "zoho_sprints.json"
25
+
26
+
27
+ def example_config_path() -> Path:
28
+ return Path.home() / ".android-harness" / "zoho_sprints.example.json"
29
+
30
+
31
+ def scratch_config_path() -> Path:
32
+ return (
33
+ Path.home()
34
+ / ".gemini"
35
+ / "antigravity"
36
+ / "scratch"
37
+ / "zoho_sprints"
38
+ / "zoho_config.json"
39
+ )
40
+
41
+
42
+ def candidate_config_paths() -> list[Path]:
43
+ paths: list[Path] = []
44
+ env = (os.environ.get(ENV_CONFIG) or "").strip()
45
+ if env:
46
+ paths.append(Path(env).expanduser())
47
+ paths.append(home_config_path())
48
+ paths.append(scratch_config_path())
49
+ seen: set[Path] = set()
50
+ out: list[Path] = []
51
+ for path in paths:
52
+ try:
53
+ resolved = path.resolve()
54
+ except OSError:
55
+ continue
56
+ if resolved in seen:
57
+ continue
58
+ seen.add(resolved)
59
+ out.append(path)
60
+ return out
61
+
62
+
63
+ def resolve_config_path() -> Path | None:
64
+ for path in candidate_config_paths():
65
+ if path.is_file() and path.stat().st_size > 0:
66
+ return path
67
+ return None
68
+
69
+
70
+ def kit_server_path(agents_root: Path) -> Path:
71
+ return agents_root / "mcp" / "zoho_sprints" / "server.py"
72
+
73
+
74
+ def json_contains_secret_keys(value: object) -> bool:
75
+ if isinstance(value, dict):
76
+ for key, child in value.items():
77
+ if str(key).lower() in SECRET_KEYS:
78
+ return True
79
+ if json_contains_secret_keys(child):
80
+ return True
81
+ return False
82
+ if isinstance(value, list):
83
+ return any(json_contains_secret_keys(item) for item in value)
84
+ return False
85
+
86
+
87
+ def text_contains_secret_values(text: str, secret_file: Path) -> bool:
88
+ """True if any nonempty string value from secret_file appears in text."""
89
+ import json
90
+
91
+ try:
92
+ data = json.loads(secret_file.read_text(encoding="utf-8"))
93
+ except (OSError, json.JSONDecodeError):
94
+ return False
95
+ if not isinstance(data, dict):
96
+ return False
97
+ for key, raw in data.items():
98
+ if str(key).lower() not in SECRET_KEYS:
99
+ continue
100
+ if not isinstance(raw, str):
101
+ continue
102
+ token = raw.strip()
103
+ if len(token) >= 8 and token in text:
104
+ return True
105
+ return False
@@ -0,0 +1,92 @@
1
+ """DNS fallback queries using direct UDP packets to public resolvers."""
2
+ from __future__ import annotations
3
+
4
+ import secrets
5
+ import socket
6
+ import struct
7
+ from typing import Optional
8
+
9
+
10
+ def _dns_query_fallback(
11
+ hostname: str,
12
+ dns_servers: tuple[str, ...] = ("8.8.8.8", "1.1.1.1"),
13
+ ) -> Optional[str]:
14
+ tx_id = secrets.randbelow(65535) + 1
15
+ packet = struct.pack(">HHHHHH", tx_id, 0x0100, 1, 0, 0, 0)
16
+ for part in hostname.split("."):
17
+ packet += struct.pack("B", len(part)) + part.encode("ascii")
18
+ packet += b"\x00\x00\x01\x00\x01"
19
+ for dns_ip in dns_servers:
20
+ sock = None
21
+ data = b""
22
+ try:
23
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
24
+ sock.settimeout(2.0)
25
+ sock.sendto(packet, (dns_ip, 53))
26
+ data, _ = sock.recvfrom(1024)
27
+ except Exception:
28
+ continue
29
+ finally:
30
+ if sock:
31
+ try:
32
+ sock.close()
33
+ except Exception:
34
+ pass
35
+ if len(data) < 12:
36
+ continue
37
+
38
+ resp_tx_id = struct.unpack(">H", data[0:2])[0]
39
+ if resp_tx_id != tx_id:
40
+ continue
41
+ idx = 12
42
+ while idx < len(data) and data[idx] != 0:
43
+ if (data[idx] & 0xC0) == 0xC0:
44
+ idx += 2
45
+ break
46
+ idx += 1 + data[idx]
47
+ else:
48
+ idx += 5
49
+ if idx > len(data):
50
+ continue
51
+ ancount = struct.unpack(">H", data[6:8])[0]
52
+ for _ in range(ancount):
53
+ if idx >= len(data):
54
+ break
55
+ if (data[idx] & 0xC0) == 0xC0:
56
+ idx += 2
57
+ else:
58
+ while idx < len(data) and data[idx] != 0:
59
+ idx += 1 + data[idx]
60
+ idx += 1
61
+ if idx + 10 > len(data):
62
+ break
63
+ rtype, _rclass, _ttl, rdlength = struct.unpack(">HHIH", data[idx : idx + 10])
64
+ idx += 10
65
+ if rtype == 1 and rdlength == 4 and idx + 4 <= len(data):
66
+ return socket.inet_ntoa(data[idx : idx + 4])
67
+ idx += rdlength
68
+ return None
69
+
70
+
71
+ def apply_dns_fallback() -> None:
72
+ orig = socket.getaddrinfo
73
+ cache: dict[str, str] = {}
74
+
75
+ def custom(host, port, family=0, type=0, proto=0, flags=0):
76
+ if isinstance(host, str) and "zoho" in host.lower():
77
+ if host not in cache:
78
+ resolved = _dns_query_fallback(host)
79
+ if resolved:
80
+ cache[host] = resolved
81
+ if host in cache:
82
+ return orig(cache[host], port, family, type, proto, flags)
83
+ try:
84
+ return orig(host, port, family, type, proto, flags)
85
+ except Exception:
86
+ resolved = _dns_query_fallback(host) if isinstance(host, str) else None
87
+ if resolved:
88
+ cache[host] = resolved
89
+ return orig(resolved, port, family, type, proto, flags)
90
+ raise
91
+
92
+ socket.getaddrinfo = custom # type: ignore[method-assign]
@@ -0,0 +1,132 @@
1
+ """HTML and text formatters for Zoho Sprints rich-text descriptions."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ _EMOJI_RE = re.compile(
7
+ "["
8
+ "\U0001F600-\U0001F64F"
9
+ "\U0001F300-\U0001F5FF"
10
+ "\U0001F680-\U0001F6FF"
11
+ "\U0001F1E0-\U0001F1FF"
12
+ "\U00002702-\U000027B0"
13
+ "\U0000FE00-\U0000FE0F"
14
+ "\U0001F900-\U0001F9FF"
15
+ "\U0001FA00-\U0001FA6F"
16
+ "\U0001FA70-\U0001FAFF"
17
+ "\U00002600-\U000026FF"
18
+ "\U0000200D"
19
+ "\U00002B50\U00002B05-\U00002B07\U00002934-\U00002935"
20
+ "\U000023CF\U000023E9-\U000023F3\U000023F8-\U000023FA"
21
+ "]+",
22
+ flags=re.UNICODE,
23
+ )
24
+
25
+
26
+ def _strip_emoji(text: str) -> str:
27
+ return _EMOJI_RE.sub("", text).strip()
28
+
29
+
30
+ def format_zoho_html(text: str) -> str:
31
+ if not text:
32
+ return text
33
+ text = _strip_emoji(text)
34
+ if not text:
35
+ return text
36
+ if text.strip().startswith("<div dir="):
37
+ return text
38
+ has_arabic = bool(re.search(r"[\u0600-\u06FF]", text))
39
+ code_blocks: list[str] = []
40
+
41
+ def _save_code_block(match: re.Match[str]) -> str:
42
+ code_blocks.append(match.group(1))
43
+ return f"___CODE_BLOCK_{len(code_blocks) - 1}___"
44
+
45
+ text = re.sub(r"```(?:\w+)?\n?(.*?)```", _save_code_block, text, flags=re.DOTALL)
46
+ html_parts: list[str] = []
47
+ in_list = False
48
+ list_type = "ul"
49
+
50
+ def _format_inline(s: str) -> str:
51
+ s = re.sub(
52
+ r"`([^`]+)`",
53
+ r'<code style="color: #ffffff; font-family: Consolas, Monaco, monospace; font-size: 0.95em; direction: ltr; display: inline-block; font-weight: bold;">\1</code>',
54
+ s,
55
+ )
56
+ return re.sub(r"\*\*(.+?)\*\*", r'<strong style="color: #ffffff; font-weight: 700;">\1</strong>', s)
57
+
58
+ for line in text.strip().splitlines():
59
+ stripped = line.strip()
60
+ if not stripped:
61
+ if in_list:
62
+ html_parts.append(f"</{list_type}>")
63
+ in_list = False
64
+ continue
65
+ cb_match = re.match(r"^___CODE_BLOCK_(\d+)___$", stripped)
66
+ if cb_match:
67
+ if in_list:
68
+ html_parts.append(f"</{list_type}>")
69
+ in_list = False
70
+ code_content = code_blocks[int(cb_match.group(1))].strip()
71
+ code_content = code_content.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
72
+ html_parts.append(
73
+ f'<pre style="color: #ffffff; font-family: Consolas, Monaco, monospace; font-size: 13px; direction: ltr; text-align: left; margin: 8px 0; overflow-x: auto;"><code>{code_content}</code></pre>'
74
+ )
75
+ continue
76
+ if stripped in ("---", "***", "___"):
77
+ if in_list:
78
+ html_parts.append(f"</{list_type}>")
79
+ in_list = False
80
+ html_parts.append('<hr style="border: 0; border-top: 1px solid rgba(255, 255, 255, 0.2); margin: 14px 0;">')
81
+ continue
82
+ heading = None
83
+ if stripped.startswith("# "):
84
+ heading = (stripped[2:], "1.25em", "12px 0 10px 0")
85
+ elif stripped.startswith("## "):
86
+ heading = (stripped[3:], "1.1em", "12px 0 6px 0")
87
+ elif stripped.startswith("### "):
88
+ heading = (stripped[4:], "1.05em", "10px 0 4px 0")
89
+ if heading:
90
+ if in_list:
91
+ html_parts.append(f"</{list_type}>")
92
+ in_list = False
93
+ title, size, margin = heading
94
+ html_parts.append(
95
+ f'<div style="font-size: {size}; font-weight: 700; color: #ffffff; margin: {margin};">{_format_inline(title)}</div>'
96
+ )
97
+ continue
98
+ if re.match(r"^\d+\.\s+", stripped):
99
+ if not in_list or list_type != "ol":
100
+ if in_list:
101
+ html_parts.append(f"</{list_type}>")
102
+ html_parts.append("<ol style='margin-right: 22px; padding-right: 0; margin-bottom: 10px; color: #ffffff;'>")
103
+ in_list = True
104
+ list_type = "ol"
105
+ item_text = _format_inline(re.sub(r"^\d+\.\s+", "", stripped))
106
+ html_parts.append(f"<li style='margin-bottom: 6px; color: #ffffff; font-size: 14px;'>{item_text}</li>")
107
+ continue
108
+ if stripped.startswith("- ") or stripped.startswith("* "):
109
+ if not in_list or list_type != "ul":
110
+ if in_list:
111
+ html_parts.append(f"</{list_type}>")
112
+ html_parts.append("<ul style='margin-right: 22px; padding-right: 0; margin-bottom: 10px; color: #ffffff;'>")
113
+ in_list = True
114
+ list_type = "ul"
115
+ html_parts.append(
116
+ f"<li style='margin-bottom: 6px; color: #ffffff; font-size: 14px;'>{_format_inline(stripped[2:])}</li>"
117
+ )
118
+ continue
119
+ if in_list:
120
+ html_parts.append(f"</{list_type}>")
121
+ in_list = False
122
+ html_parts.append(f"<p style='margin: 6px 0; color: #ffffff; font-size: 14px;'>{_format_inline(stripped)}</p>")
123
+ if in_list:
124
+ html_parts.append(f"</{list_type}>")
125
+ inner = "\n".join(html_parts)
126
+ if has_arabic:
127
+ return (
128
+ '<div dir="rtl" style="text-align: right; direction: rtl; line-height: 1.85; '
129
+ "font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Tahoma, Arial, sans-serif; "
130
+ f'color: #ffffff; font-size: 14px;">\n{inner}\n</div>'
131
+ )
132
+ return inner