wxcli 1.2.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.
Files changed (413) hide show
  1. wxcli/__init__.py +4 -0
  2. wxcli/_playbook/.claude/agents/migration-advisor.md +239 -0
  3. wxcli/_playbook/.claude/agents/wxc-calling-builder.md +946 -0
  4. wxcli/_playbook/.claude/rules/cleanup.md +29 -0
  5. wxcli/_playbook/.claude/rules/cucm-migration.md +58 -0
  6. wxcli/_playbook/.claude/rules/org-health.md +14 -0
  7. wxcli/_playbook/.claude/settings.json +8 -0
  8. wxcli/_playbook/.claude/skills/audit-compliance/SKILL.md +515 -0
  9. wxcli/_playbook/.claude/skills/call-control/SKILL.md +747 -0
  10. wxcli/_playbook/.claude/skills/configure-features/SKILL.md +623 -0
  11. wxcli/_playbook/.claude/skills/configure-routing/SKILL.md +627 -0
  12. wxcli/_playbook/.claude/skills/contact-center/SKILL.md +1181 -0
  13. wxcli/_playbook/.claude/skills/cucm-migrate/SKILL.md +820 -0
  14. wxcli/_playbook/.claude/skills/customer-assist/SKILL.md +443 -0
  15. wxcli/_playbook/.claude/skills/device-platform/SKILL.md +407 -0
  16. wxcli/_playbook/.claude/skills/manage-call-settings/SKILL.md +757 -0
  17. wxcli/_playbook/.claude/skills/manage-devices/SKILL.md +790 -0
  18. wxcli/_playbook/.claude/skills/manage-identity/SKILL.md +638 -0
  19. wxcli/_playbook/.claude/skills/manage-licensing/SKILL.md +528 -0
  20. wxcli/_playbook/.claude/skills/manage-meetings/SKILL.md +708 -0
  21. wxcli/_playbook/.claude/skills/messaging-bots/SKILL.md +365 -0
  22. wxcli/_playbook/.claude/skills/messaging-spaces/SKILL.md +520 -0
  23. wxcli/_playbook/.claude/skills/org-health/SKILL.md +159 -0
  24. wxcli/_playbook/.claude/skills/provision-calling/SKILL.md +421 -0
  25. wxcli/_playbook/.claude/skills/query-live/SKILL.md +183 -0
  26. wxcli/_playbook/.claude/skills/query-live/domains/call-flow-trace.md +223 -0
  27. wxcli/_playbook/.claude/skills/query-live/domains/call-history.md +44 -0
  28. wxcli/_playbook/.claude/skills/query-live/domains/features.md +169 -0
  29. wxcli/_playbook/.claude/skills/query-live/domains/numbers.md +93 -0
  30. wxcli/_playbook/.claude/skills/query-live/domains/people-and-settings.md +146 -0
  31. wxcli/_playbook/.claude/skills/query-live/domains/routing.md +124 -0
  32. wxcli/_playbook/.claude/skills/reporting/SKILL.md +501 -0
  33. wxcli/_playbook/.claude/skills/reporting/references/cdr-recipes.md +1628 -0
  34. wxcli/_playbook/.claude/skills/reporting-cc/SKILL.md +251 -0
  35. wxcli/_playbook/.claude/skills/reporting-meetings/SKILL.md +290 -0
  36. wxcli/_playbook/.claude/skills/teardown/SKILL.md +214 -0
  37. wxcli/_playbook/.claude/skills/video-mesh/SKILL.md +379 -0
  38. wxcli/_playbook/.claude/skills/wxc-calling-debug/SKILL.md +475 -0
  39. wxcli/_playbook/CLAUDE.md +373 -0
  40. wxcli/_playbook/docs/reference/CLAUDE.md +25 -0
  41. wxcli/_playbook/docs/reference/admin-apps-data.md +643 -0
  42. wxcli/_playbook/docs/reference/admin-audit-security.md +322 -0
  43. wxcli/_playbook/docs/reference/admin-hybrid.md +383 -0
  44. wxcli/_playbook/docs/reference/admin-identity-scim.md +1007 -0
  45. wxcli/_playbook/docs/reference/admin-licensing.md +414 -0
  46. wxcli/_playbook/docs/reference/admin-org-management.md +586 -0
  47. wxcli/_playbook/docs/reference/admin-partner.md +357 -0
  48. wxcli/_playbook/docs/reference/archive/wxc-sdk-patterns.md +1229 -0
  49. wxcli/_playbook/docs/reference/archive/wxcadm-advanced.md +835 -0
  50. wxcli/_playbook/docs/reference/archive/wxcadm-core.md +743 -0
  51. wxcli/_playbook/docs/reference/archive/wxcadm-devices-workspaces.md +1305 -0
  52. wxcli/_playbook/docs/reference/archive/wxcadm-features.md +849 -0
  53. wxcli/_playbook/docs/reference/archive/wxcadm-locations.md +938 -0
  54. wxcli/_playbook/docs/reference/archive/wxcadm-person.md +1255 -0
  55. wxcli/_playbook/docs/reference/archive/wxcadm-routing.md +1262 -0
  56. wxcli/_playbook/docs/reference/authentication.md +965 -0
  57. wxcli/_playbook/docs/reference/call-control.md +1205 -0
  58. wxcli/_playbook/docs/reference/call-features-additional.md +2541 -0
  59. wxcli/_playbook/docs/reference/call-features-major.md +1320 -0
  60. wxcli/_playbook/docs/reference/call-routing.md +2304 -0
  61. wxcli/_playbook/docs/reference/contact-center-analytics.md +1049 -0
  62. wxcli/_playbook/docs/reference/contact-center-core.md +1614 -0
  63. wxcli/_playbook/docs/reference/contact-center-journey.md +457 -0
  64. wxcli/_playbook/docs/reference/contact-center-routing.md +1138 -0
  65. wxcli/_playbook/docs/reference/devices-core.md +1869 -0
  66. wxcli/_playbook/docs/reference/devices-dect.md +1150 -0
  67. wxcli/_playbook/docs/reference/devices-platform.md +467 -0
  68. wxcli/_playbook/docs/reference/devices-workspaces.md +1426 -0
  69. wxcli/_playbook/docs/reference/emergency-services.md +1122 -0
  70. wxcli/_playbook/docs/reference/location-calling-core.md +2037 -0
  71. wxcli/_playbook/docs/reference/location-calling-media.md +1334 -0
  72. wxcli/_playbook/docs/reference/location-recording-advanced.md +1482 -0
  73. wxcli/_playbook/docs/reference/meetings-content.md +473 -0
  74. wxcli/_playbook/docs/reference/meetings-core.md +903 -0
  75. wxcli/_playbook/docs/reference/meetings-infrastructure.md +899 -0
  76. wxcli/_playbook/docs/reference/meetings-settings.md +850 -0
  77. wxcli/_playbook/docs/reference/messaging-bots.md +1091 -0
  78. wxcli/_playbook/docs/reference/messaging-spaces.md +695 -0
  79. wxcli/_playbook/docs/reference/person-call-settings-behavior.md +1639 -0
  80. wxcli/_playbook/docs/reference/person-call-settings-handling.md +1394 -0
  81. wxcli/_playbook/docs/reference/person-call-settings-media.md +1898 -0
  82. wxcli/_playbook/docs/reference/person-call-settings-permissions.md +1329 -0
  83. wxcli/_playbook/docs/reference/provisioning.md +1366 -0
  84. wxcli/_playbook/docs/reference/reporting-analytics.md +1318 -0
  85. wxcli/_playbook/docs/reference/self-service-call-settings.md +1020 -0
  86. wxcli/_playbook/docs/reference/virtual-lines.md +1132 -0
  87. wxcli/_playbook/docs/reference/webhooks-events.md +1444 -0
  88. wxcli/_playbook/docs/reference/wxcadm-xsi-realtime.md +953 -0
  89. wxcli/_version.py +24 -0
  90. wxcli/auth.py +137 -0
  91. wxcli/commands/__init__.py +0 -0
  92. wxcli/commands/_registry.py +181 -0
  93. wxcli/commands/activation_email.py +103 -0
  94. wxcli/commands/admin_recordings.py +585 -0
  95. wxcli/commands/analytics.py +105 -0
  96. wxcli/commands/announcement_playlists.py +219 -0
  97. wxcli/commands/announcements.py +519 -0
  98. wxcli/commands/archive_users.py +36 -0
  99. wxcli/commands/attachment_actions.py +60 -0
  100. wxcli/commands/audit_events.py +83 -0
  101. wxcli/commands/authorizations.py +113 -0
  102. wxcli/commands/auto_attendant.py +642 -0
  103. wxcli/commands/broadworks_billing_reports.py +126 -0
  104. wxcli/commands/broadworks_enterprises.py +160 -0
  105. wxcli/commands/broadworks_subscribers.py +319 -0
  106. wxcli/commands/broadworks_workspaces.py +108 -0
  107. wxcli/commands/call_controls.py +1304 -0
  108. wxcli/commands/call_park.py +494 -0
  109. wxcli/commands/call_pickup.py +229 -0
  110. wxcli/commands/call_queue.py +1878 -0
  111. wxcli/commands/call_recording.py +707 -0
  112. wxcli/commands/call_routing.py +1610 -0
  113. wxcli/commands/call_settings_for_me_phase_5.py +212 -0
  114. wxcli/commands/caller_reputation.py +169 -0
  115. wxcli/commands/calling_service.py +305 -0
  116. wxcli/commands/cc_address_book.py +770 -0
  117. wxcli/commands/cc_agent_greetings.py +242 -0
  118. wxcli/commands/cc_agent_summaries.py +39 -0
  119. wxcli/commands/cc_agent_wellbeing.py +216 -0
  120. wxcli/commands/cc_agents.py +425 -0
  121. wxcli/commands/cc_ai_assistant.py +55 -0
  122. wxcli/commands/cc_ai_feature.py +294 -0
  123. wxcli/commands/cc_audio_files.py +149 -0
  124. wxcli/commands/cc_auto_csat.py +311 -0
  125. wxcli/commands/cc_aux_code.py +458 -0
  126. wxcli/commands/cc_business_hour.py +353 -0
  127. wxcli/commands/cc_call_monitoring.py +228 -0
  128. wxcli/commands/cc_callbacks.py +194 -0
  129. wxcli/commands/cc_campaign.py +212 -0
  130. wxcli/commands/cc_captures.py +39 -0
  131. wxcli/commands/cc_contact_list.py +169 -0
  132. wxcli/commands/cc_contact_number.py +295 -0
  133. wxcli/commands/cc_data_sources.py +235 -0
  134. wxcli/commands/cc_desktop_layout.py +380 -0
  135. wxcli/commands/cc_desktop_profile.py +322 -0
  136. wxcli/commands/cc_dial_number.py +511 -0
  137. wxcli/commands/cc_dial_plan.py +347 -0
  138. wxcli/commands/cc_dnc.py +101 -0
  139. wxcli/commands/cc_entry_point.py +545 -0
  140. wxcli/commands/cc_ewt.py +51 -0
  141. wxcli/commands/cc_flow.py +477 -0
  142. wxcli/commands/cc_global_vars.py +430 -0
  143. wxcli/commands/cc_holiday_list.py +335 -0
  144. wxcli/commands/cc_journey.py +1261 -0
  145. wxcli/commands/cc_legacy_flows.py +49 -0
  146. wxcli/commands/cc_multimedia_profile.py +436 -0
  147. wxcli/commands/cc_notification.py +51 -0
  148. wxcli/commands/cc_outdial_ani.py +604 -0
  149. wxcli/commands/cc_overrides.py +341 -0
  150. wxcli/commands/cc_queue.py +1083 -0
  151. wxcli/commands/cc_queue_stats.py +55 -0
  152. wxcli/commands/cc_realtime.py +51 -0
  153. wxcli/commands/cc_resource_collection.py +312 -0
  154. wxcli/commands/cc_search.py +46 -0
  155. wxcli/commands/cc_site.py +400 -0
  156. wxcli/commands/cc_skill.py +350 -0
  157. wxcli/commands/cc_skill_profile.py +289 -0
  158. wxcli/commands/cc_subscriptions.py +426 -0
  159. wxcli/commands/cc_summaries.py +130 -0
  160. wxcli/commands/cc_tasks.py +844 -0
  161. wxcli/commands/cc_team.py +325 -0
  162. wxcli/commands/cc_user_profiles.py +349 -0
  163. wxcli/commands/cc_users.py +701 -0
  164. wxcli/commands/cc_work_types.py +363 -0
  165. wxcli/commands/cdr.py +89 -0
  166. wxcli/commands/classifications.py +38 -0
  167. wxcli/commands/cleanup.py +1427 -0
  168. wxcli/commands/client_settings.py +72 -0
  169. wxcli/commands/conference.py +328 -0
  170. wxcli/commands/configure.py +72 -0
  171. wxcli/commands/converged_recordings.py +347 -0
  172. wxcli/commands/converged_recordings_export.py +310 -0
  173. wxcli/commands/cq_playlists.py +42 -0
  174. wxcli/commands/cucm.py +2794 -0
  175. wxcli/commands/cucm_config.py +129 -0
  176. wxcli/commands/customer_assist.py +373 -0
  177. wxcli/commands/data_sources.py +227 -0
  178. wxcli/commands/dect_devices.py +787 -0
  179. wxcli/commands/device_configurations.py +77 -0
  180. wxcli/commands/device_dynamic_settings.py +320 -0
  181. wxcli/commands/device_settings.py +1489 -0
  182. wxcli/commands/devices.py +280 -0
  183. wxcli/commands/domains.py +142 -0
  184. wxcli/commands/ecm.py +178 -0
  185. wxcli/commands/emergency_services.py +827 -0
  186. wxcli/commands/events.py +83 -0
  187. wxcli/commands/external_voicemail.py +52 -0
  188. wxcli/commands/groups.py +206 -0
  189. wxcli/commands/guest_management.py +76 -0
  190. wxcli/commands/hds.py +340 -0
  191. wxcli/commands/hot_desk.py +67 -0
  192. wxcli/commands/hot_desking_members.py +118 -0
  193. wxcli/commands/hot_desking_portal.py +124 -0
  194. wxcli/commands/hunt_group.py +577 -0
  195. wxcli/commands/hybrid_clusters.py +71 -0
  196. wxcli/commands/hybrid_connectors.py +67 -0
  197. wxcli/commands/identity_org.py +159 -0
  198. wxcli/commands/init_playbook.py +155 -0
  199. wxcli/commands/licenses.py +106 -0
  200. wxcli/commands/live_monitoring.py +40 -0
  201. wxcli/commands/location_call_handling.py +577 -0
  202. wxcli/commands/location_schedules.py +341 -0
  203. wxcli/commands/location_settings.py +1297 -0
  204. wxcli/commands/location_voicemail.py +494 -0
  205. wxcli/commands/locations.py +336 -0
  206. wxcli/commands/meeting_captions.py +110 -0
  207. wxcli/commands/meeting_chats.py +63 -0
  208. wxcli/commands/meeting_invitees.py +234 -0
  209. wxcli/commands/meeting_messages.py +28 -0
  210. wxcli/commands/meeting_participants.py +278 -0
  211. wxcli/commands/meeting_polls.py +111 -0
  212. wxcli/commands/meeting_preferences.py +515 -0
  213. wxcli/commands/meeting_qa.py +78 -0
  214. wxcli/commands/meeting_qualities.py +43 -0
  215. wxcli/commands/meeting_reports.py +101 -0
  216. wxcli/commands/meeting_session_types.py +105 -0
  217. wxcli/commands/meeting_site.py +57 -0
  218. wxcli/commands/meeting_slido.py +44 -0
  219. wxcli/commands/meeting_summaries.py +92 -0
  220. wxcli/commands/meeting_tracking_codes.py +233 -0
  221. wxcli/commands/meeting_transcripts.py +230 -0
  222. wxcli/commands/meetings.py +1997 -0
  223. wxcli/commands/memberships.py +164 -0
  224. wxcli/commands/messages.py +213 -0
  225. wxcli/commands/mode_management.py +244 -0
  226. wxcli/commands/my_call_settings.py +3577 -0
  227. wxcli/commands/numbers.py +405 -0
  228. wxcli/commands/operating_modes.py +410 -0
  229. wxcli/commands/org_contacts.py +284 -0
  230. wxcli/commands/org_health_cli.py +55 -0
  231. wxcli/commands/org_settings.py +71 -0
  232. wxcli/commands/organizations.py +83 -0
  233. wxcli/commands/paging_group.py +257 -0
  234. wxcli/commands/partner_admins.py +141 -0
  235. wxcli/commands/partner_reports.py +188 -0
  236. wxcli/commands/partner_tags.py +216 -0
  237. wxcli/commands/people.py +279 -0
  238. wxcli/commands/person_call_settings.py +71 -0
  239. wxcli/commands/pstn.py +280 -0
  240. wxcli/commands/recording_report.py +154 -0
  241. wxcli/commands/report_templates.py +38 -0
  242. wxcli/commands/reports.py +144 -0
  243. wxcli/commands/resource_group_memberships.py +160 -0
  244. wxcli/commands/resource_groups.py +67 -0
  245. wxcli/commands/roles.py +63 -0
  246. wxcli/commands/room_tabs.py +160 -0
  247. wxcli/commands/rooms.py +225 -0
  248. wxcli/commands/scim_bulk.py +46 -0
  249. wxcli/commands/scim_groups.py +246 -0
  250. wxcli/commands/scim_schemas.py +82 -0
  251. wxcli/commands/scim_users.py +283 -0
  252. wxcli/commands/security_audit.py +54 -0
  253. wxcli/commands/service_apps.py +51 -0
  254. wxcli/commands/single_number_reach.py +209 -0
  255. wxcli/commands/team_memberships.py +155 -0
  256. wxcli/commands/teams.py +153 -0
  257. wxcli/commands/ucm_profile.py +42 -0
  258. wxcli/commands/update.py +219 -0
  259. wxcli/commands/user_settings.py +5116 -0
  260. wxcli/commands/video_mesh.py +1090 -0
  261. wxcli/commands/virtual_extensions.py +502 -0
  262. wxcli/commands/virtual_line_settings.py +2072 -0
  263. wxcli/commands/webhooks.py +176 -0
  264. wxcli/commands/wholesale_billing_reports.py +147 -0
  265. wxcli/commands/wholesale_provisioning.py +523 -0
  266. wxcli/commands/workspace_locations.py +339 -0
  267. wxcli/commands/workspace_metrics.py +103 -0
  268. wxcli/commands/workspace_personalization.py +70 -0
  269. wxcli/commands/workspace_settings.py +3347 -0
  270. wxcli/commands/workspaces.py +279 -0
  271. wxcli/commands/xapi.py +114 -0
  272. wxcli/config.py +139 -0
  273. wxcli/errors.py +77 -0
  274. wxcli/main.py +210 -0
  275. wxcli/migration/CLAUDE.md +106 -0
  276. wxcli/migration/__init__.py +1 -0
  277. wxcli/migration/advisory/CLAUDE.md +196 -0
  278. wxcli/migration/advisory/__init__.py +46 -0
  279. wxcli/migration/advisory/advisor.py +107 -0
  280. wxcli/migration/advisory/advisory_patterns.py +2552 -0
  281. wxcli/migration/advisory/recommendation_rules.py +835 -0
  282. wxcli/migration/cucm/CLAUDE.md +74 -0
  283. wxcli/migration/cucm/__init__.py +5 -0
  284. wxcli/migration/cucm/connection.py +265 -0
  285. wxcli/migration/cucm/discovery.py +225 -0
  286. wxcli/migration/cucm/extractors/__init__.py +1 -0
  287. wxcli/migration/cucm/extractors/announcements.py +92 -0
  288. wxcli/migration/cucm/extractors/base.py +90 -0
  289. wxcli/migration/cucm/extractors/device_profiles.py +98 -0
  290. wxcli/migration/cucm/extractors/devices.py +193 -0
  291. wxcli/migration/cucm/extractors/e911.py +103 -0
  292. wxcli/migration/cucm/extractors/features.py +309 -0
  293. wxcli/migration/cucm/extractors/helpers.py +73 -0
  294. wxcli/migration/cucm/extractors/informational.py +295 -0
  295. wxcli/migration/cucm/extractors/locations.py +221 -0
  296. wxcli/migration/cucm/extractors/moh.py +100 -0
  297. wxcli/migration/cucm/extractors/remote_destinations.py +96 -0
  298. wxcli/migration/cucm/extractors/routing.py +493 -0
  299. wxcli/migration/cucm/extractors/shared_lines.py +146 -0
  300. wxcli/migration/cucm/extractors/templates.py +229 -0
  301. wxcli/migration/cucm/extractors/tier4.py +173 -0
  302. wxcli/migration/cucm/extractors/users.py +240 -0
  303. wxcli/migration/cucm/extractors/voicemail.py +243 -0
  304. wxcli/migration/cucm/extractors/workspaces.py +88 -0
  305. wxcli/migration/cucm/unity_connection.py +304 -0
  306. wxcli/migration/execute/CLAUDE.md +372 -0
  307. wxcli/migration/execute/__init__.py +303 -0
  308. wxcli/migration/execute/batch.py +277 -0
  309. wxcli/migration/execute/dependency.py +483 -0
  310. wxcli/migration/execute/engine.py +964 -0
  311. wxcli/migration/execute/handlers.py +2232 -0
  312. wxcli/migration/execute/planner.py +1939 -0
  313. wxcli/migration/execute/runtime.py +571 -0
  314. wxcli/migration/export/CLAUDE.md +49 -0
  315. wxcli/migration/export/__init__.py +8 -0
  316. wxcli/migration/export/csv_export.py +126 -0
  317. wxcli/migration/export/deployment_plan.py +479 -0
  318. wxcli/migration/export/json_export.py +60 -0
  319. wxcli/migration/models.py +941 -0
  320. wxcli/migration/phone_models.py +50 -0
  321. wxcli/migration/preflight/CLAUDE.md +52 -0
  322. wxcli/migration/preflight/__init__.py +89 -0
  323. wxcli/migration/preflight/checks.py +911 -0
  324. wxcli/migration/preflight/runner.py +293 -0
  325. wxcli/migration/rate_limiter.py +144 -0
  326. wxcli/migration/report/CLAUDE.md +245 -0
  327. wxcli/migration/report/__init__.py +7 -0
  328. wxcli/migration/report/appendix.py +2437 -0
  329. wxcli/migration/report/assembler.py +183 -0
  330. wxcli/migration/report/charts.py +263 -0
  331. wxcli/migration/report/executive.py +776 -0
  332. wxcli/migration/report/explainer.py +704 -0
  333. wxcli/migration/report/helpers.py +74 -0
  334. wxcli/migration/report/ingest.py +152 -0
  335. wxcli/migration/report/notice_templates.py +192 -0
  336. wxcli/migration/report/score.py +375 -0
  337. wxcli/migration/report/styles.py +1052 -0
  338. wxcli/migration/report/user_diff.py +1052 -0
  339. wxcli/migration/report/user_notice.py +425 -0
  340. wxcli/migration/state.py +139 -0
  341. wxcli/migration/store.py +860 -0
  342. wxcli/migration/transform/CLAUDE.md +107 -0
  343. wxcli/migration/transform/__init__.py +12 -0
  344. wxcli/migration/transform/analysis_pipeline.py +453 -0
  345. wxcli/migration/transform/analyzers/__init__.py +142 -0
  346. wxcli/migration/transform/analyzers/css_permission.py +189 -0
  347. wxcli/migration/transform/analyzers/css_routing.py +335 -0
  348. wxcli/migration/transform/analyzers/device_compatibility.py +122 -0
  349. wxcli/migration/transform/analyzers/dn_ambiguity.py +131 -0
  350. wxcli/migration/transform/analyzers/duplicate_user.py +221 -0
  351. wxcli/migration/transform/analyzers/extension_conflict.py +173 -0
  352. wxcli/migration/transform/analyzers/feature_approximation.py +227 -0
  353. wxcli/migration/transform/analyzers/layout_overflow.py +174 -0
  354. wxcli/migration/transform/analyzers/location_ambiguity.py +184 -0
  355. wxcli/migration/transform/analyzers/missing_data.py +300 -0
  356. wxcli/migration/transform/analyzers/selective_call_handling.py +394 -0
  357. wxcli/migration/transform/analyzers/shared_line.py +170 -0
  358. wxcli/migration/transform/analyzers/voicemail_compatibility.py +267 -0
  359. wxcli/migration/transform/analyzers/workspace_license.py +212 -0
  360. wxcli/migration/transform/cross_reference.py +1136 -0
  361. wxcli/migration/transform/cucm_pattern.py +246 -0
  362. wxcli/migration/transform/decisions.py +301 -0
  363. wxcli/migration/transform/e164.py +145 -0
  364. wxcli/migration/transform/engine.py +256 -0
  365. wxcli/migration/transform/mappers/CLAUDE.md +123 -0
  366. wxcli/migration/transform/mappers/__init__.py +76 -0
  367. wxcli/migration/transform/mappers/announcement_mapper.py +122 -0
  368. wxcli/migration/transform/mappers/base.py +190 -0
  369. wxcli/migration/transform/mappers/button_template_mapper.py +186 -0
  370. wxcli/migration/transform/mappers/call_forwarding_mapper.py +228 -0
  371. wxcli/migration/transform/mappers/call_settings_mapper.py +144 -0
  372. wxcli/migration/transform/mappers/css_mapper.py +868 -0
  373. wxcli/migration/transform/mappers/dect_mapper.py +367 -0
  374. wxcli/migration/transform/mappers/device_layout_mapper.py +289 -0
  375. wxcli/migration/transform/mappers/device_mapper.py +227 -0
  376. wxcli/migration/transform/mappers/device_profile_mapper.py +190 -0
  377. wxcli/migration/transform/mappers/device_settings_mapper.py +464 -0
  378. wxcli/migration/transform/mappers/e911_mapper.py +129 -0
  379. wxcli/migration/transform/mappers/ecbn_mapper.py +263 -0
  380. wxcli/migration/transform/mappers/executive_assistant_mapper.py +156 -0
  381. wxcli/migration/transform/mappers/feature_mapper.py +1179 -0
  382. wxcli/migration/transform/mappers/line_mapper.py +310 -0
  383. wxcli/migration/transform/mappers/location_mapper.py +235 -0
  384. wxcli/migration/transform/mappers/moh_mapper.py +114 -0
  385. wxcli/migration/transform/mappers/monitoring_mapper.py +221 -0
  386. wxcli/migration/transform/mappers/receptionist_mapper.py +245 -0
  387. wxcli/migration/transform/mappers/routing_mapper.py +821 -0
  388. wxcli/migration/transform/mappers/snr_mapper.py +201 -0
  389. wxcli/migration/transform/mappers/softkey_mapper.py +205 -0
  390. wxcli/migration/transform/mappers/user_mapper.py +264 -0
  391. wxcli/migration/transform/mappers/voicemail_group_mapper.py +286 -0
  392. wxcli/migration/transform/mappers/voicemail_mapper.py +514 -0
  393. wxcli/migration/transform/mappers/workspace_mapper.py +464 -0
  394. wxcli/migration/transform/normalizers.py +2008 -0
  395. wxcli/migration/transform/pattern_converter.py +106 -0
  396. wxcli/migration/transform/pipeline.py +271 -0
  397. wxcli/migration/transform/rules.py +255 -0
  398. wxcli/org_health/CLAUDE.md +63 -0
  399. wxcli/org_health/__init__.py +7 -0
  400. wxcli/org_health/analyze.py +93 -0
  401. wxcli/org_health/checks.py +458 -0
  402. wxcli/org_health/collector.py +62 -0
  403. wxcli/org_health/models.py +78 -0
  404. wxcli/org_health/report.py +300 -0
  405. wxcli/output.py +101 -0
  406. wxcli-1.2.0.dist-info/METADATA +505 -0
  407. wxcli-1.2.0.dist-info/RECORD +413 -0
  408. wxcli-1.2.0.dist-info/WHEEL +5 -0
  409. wxcli-1.2.0.dist-info/entry_points.txt +2 -0
  410. wxcli-1.2.0.dist-info/licenses/LICENSE +191 -0
  411. wxcli-1.2.0.dist-info/scm_file_list.json +751 -0
  412. wxcli-1.2.0.dist-info/scm_version.json +8 -0
  413. wxcli-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ REQUIRED_FILES = [
8
+ "auto_attendants.json",
9
+ "call_queues.json",
10
+ "hunt_groups.json",
11
+ "voicemail_groups.json",
12
+ "paging_groups.json",
13
+ "call_parks.json",
14
+ "devices.json",
15
+ "workspaces.json",
16
+ "users.json",
17
+ "dial_plans.json",
18
+ "route_groups.json",
19
+ "route_lists.json",
20
+ "trunks.json",
21
+ "numbers.json",
22
+ ]
23
+
24
+ DETAIL_DIRS = ["call_queue_details", "outgoing_permissions"]
25
+
26
+
27
+ def load_manifest(collected_dir: Path) -> dict:
28
+ manifest_path = collected_dir / "manifest.json"
29
+ if not manifest_path.exists():
30
+ raise FileNotFoundError(f"manifest.json not found in {collected_dir}")
31
+ return json.loads(manifest_path.read_text())
32
+
33
+
34
+ def validate_collection(collected_dir: Path) -> list[str]:
35
+ errors = []
36
+ if not (collected_dir / "manifest.json").exists():
37
+ errors.append("Missing required file: manifest.json")
38
+ for filename in REQUIRED_FILES:
39
+ if not (collected_dir / filename).exists():
40
+ errors.append(f"Missing required file: {filename}")
41
+ return errors
42
+
43
+
44
+ def _load_detail_dir(detail_path: Path) -> dict[str, Any]:
45
+ result = {}
46
+ if detail_path.exists():
47
+ for f in sorted(detail_path.glob("*.json")):
48
+ key = f.stem
49
+ result[key] = json.loads(f.read_text())
50
+ return result
51
+
52
+
53
+ def load_collected_data(collected_dir: Path) -> dict[str, Any]:
54
+ data: dict[str, Any] = {}
55
+ data["manifest"] = load_manifest(collected_dir)
56
+ for filename in REQUIRED_FILES:
57
+ key = filename.removesuffix(".json")
58
+ filepath = collected_dir / filename
59
+ data[key] = json.loads(filepath.read_text()) if filepath.exists() else []
60
+ for dirname in DETAIL_DIRS:
61
+ data[dirname] = _load_detail_dir(collected_dir / dirname)
62
+ return data
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, asdict
4
+
5
+
6
+ @dataclass
7
+ class Finding:
8
+ check_name: str
9
+ category: str
10
+ severity: str
11
+ title: str
12
+ detail: str
13
+ affected_items: list[dict]
14
+ recommendation: str
15
+
16
+ def to_dict(self) -> dict:
17
+ return asdict(self)
18
+
19
+
20
+ @dataclass
21
+ class CategoryScore:
22
+ category: str
23
+ display_name: str
24
+ high_count: int
25
+ medium_count: int
26
+ low_count: int
27
+ info_count: int
28
+ findings: list[Finding]
29
+
30
+ @classmethod
31
+ def from_findings(cls, category: str, display_name: str, findings: list[Finding]) -> CategoryScore:
32
+ return cls(
33
+ category=category,
34
+ display_name=display_name,
35
+ high_count=sum(1 for f in findings if f.severity == "HIGH"),
36
+ medium_count=sum(1 for f in findings if f.severity == "MEDIUM"),
37
+ low_count=sum(1 for f in findings if f.severity == "LOW"),
38
+ info_count=sum(1 for f in findings if f.severity == "INFO"),
39
+ findings=findings,
40
+ )
41
+
42
+ def to_dict(self) -> dict:
43
+ return asdict(self)
44
+
45
+
46
+ @dataclass
47
+ class OrgStats:
48
+ total_users: int
49
+ total_devices: int
50
+ total_auto_attendants: int
51
+ total_call_queues: int
52
+ total_hunt_groups: int
53
+ total_trunks: int
54
+ total_locations: int
55
+ sampled_users_for_permissions: int
56
+
57
+ def to_dict(self) -> dict:
58
+ return asdict(self)
59
+
60
+
61
+ @dataclass
62
+ class HealthResult:
63
+ org_name: str
64
+ org_id: str
65
+ collected_at: str
66
+ categories: dict[str, CategoryScore]
67
+ findings: list[Finding]
68
+ stats: OrgStats
69
+
70
+ def to_dict(self) -> dict:
71
+ return {
72
+ "org_name": self.org_name,
73
+ "org_id": self.org_id,
74
+ "collected_at": self.collected_at,
75
+ "categories": {k: v.to_dict() for k, v in self.categories.items()},
76
+ "findings": [f.to_dict() for f in self.findings],
77
+ "stats": self.stats.to_dict(),
78
+ }
@@ -0,0 +1,300 @@
1
+ # src/wxcli/org_health/report.py
2
+ """HTML report generator for org health assessment.
3
+
4
+ Reuses CSS design system and chart functions from the migration report module.
5
+
6
+ Usage:
7
+ python3.14 -m wxcli.org_health.report <results-dir> --brand "Name" --prepared-by "SE"
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import html as html_mod
13
+ import json
14
+ import sys
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ from wxcli.migration.report.styles import REPORT_CSS, GOOGLE_FONTS_LINKS
19
+ from wxcli.migration.report.charts import horizontal_bar_chart
20
+ from wxcli.org_health.models import CategoryScore, Finding, HealthResult, OrgStats
21
+
22
+ SEVERITY_COLORS = {
23
+ "HIGH": "#C62828",
24
+ "MEDIUM": "#EF6C00",
25
+ "LOW": "#F9A825",
26
+ "INFO": "#0277BD",
27
+ }
28
+
29
+ SEVERITY_ORDER = ["HIGH", "MEDIUM", "LOW", "INFO"]
30
+
31
+ CATEGORY_ORDER = ["security", "routing", "feature_utilization", "device_health"]
32
+
33
+ CATEGORY_ICONS = {
34
+ "security": "🛡",
35
+ "routing": "🔀",
36
+ "feature_utilization": "📊",
37
+ "device_health": "📱",
38
+ }
39
+
40
+
41
+ def generate_report(result: HealthResult, *, brand: str, prepared_by: str) -> str:
42
+ parts = [
43
+ _html_head(brand),
44
+ _page_header(brand, prepared_by, result.collected_at),
45
+ _org_overview(result.stats),
46
+ _summary_cards(result.categories),
47
+ ]
48
+ for cat_key in CATEGORY_ORDER:
49
+ cat = result.categories.get(cat_key)
50
+ if cat:
51
+ parts.append(_category_section(cat))
52
+ parts.append(_footer(result))
53
+ parts.append("</div></body></html>")
54
+ return "\n".join(parts)
55
+
56
+
57
+ def _html_head(brand: str) -> str:
58
+ return f"""<!DOCTYPE html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="UTF-8">
62
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
63
+ <title>{html_mod.escape(brand)} — Org Health Assessment</title>
64
+ {GOOGLE_FONTS_LINKS}
65
+ <style>
66
+ {REPORT_CSS}
67
+ {_EXTRA_CSS}
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <div class="detail-panel" style="margin-left:0; max-width:960px; margin:0 auto; padding:2rem;">"""
72
+
73
+
74
+ _EXTRA_CSS = """
75
+ /* Org health report additions */
76
+ .summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin: 2rem 0; }
77
+ .summary-card { background: var(--warm-50, #fdf8f3); border: 1px solid var(--warm-200, #f0dcc6);
78
+ border-radius: 8px; padding: 1.25rem; }
79
+ .summary-card h3 { font-family: 'Lora', serif; font-size: 1.1rem; margin-bottom: 0.75rem; }
80
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem;
81
+ font-weight: 600; color: white; margin-right: 4px; }
82
+ .badge-high { background: #C62828; }
83
+ .badge-medium { background: #EF6C00; }
84
+ .badge-low { background: #F9A825; color: #333; }
85
+ .badge-info { background: #0277BD; }
86
+ .finding-card { border-left: 4px solid; padding: 1rem 1.25rem; margin: 1rem 0;
87
+ background: white; border-radius: 0 6px 6px 0; }
88
+ .finding-card.severity-high { border-color: #C62828; }
89
+ .finding-card.severity-medium { border-color: #EF6C00; }
90
+ .finding-card.severity-low { border-color: #F9A825; }
91
+ .finding-card.severity-info { border-color: #0277BD; }
92
+ .finding-title { font-weight: 600; margin-bottom: 0.5rem; }
93
+ .finding-detail { color: var(--slate-600, #636e7e); margin-bottom: 0.75rem; }
94
+ .recommendation { background: var(--warm-50, #fdf8f3); border: 1px solid var(--warm-200, #f0dcc6);
95
+ border-radius: 6px; padding: 0.75rem 1rem; font-size: 0.9rem; }
96
+ .recommendation strong { color: var(--primary, #00897B); }
97
+ .affected-table { width: 100%; border-collapse: collapse; margin: 0.75rem 0; font-size: 0.85rem; }
98
+ .affected-table th { text-align: left; text-transform: uppercase; font-size: 0.7rem;
99
+ letter-spacing: 0.05em; color: var(--slate-500, #8e97a5); padding: 4px 8px;
100
+ border-bottom: 1px solid var(--warm-200, #f0dcc6); }
101
+ .affected-table td { padding: 4px 8px; border-bottom: 1px solid var(--warm-100, #f9efe4); }
102
+ .stat-strip { display: flex; gap: 1rem; flex-wrap: wrap; margin: 1.5rem 0; }
103
+ .checkmark-box { text-align: center; padding: 2rem; color: var(--primary, #00897B); }
104
+ .checkmark-box .check { font-size: 2rem; }
105
+ """
106
+
107
+
108
+ def _page_header(brand: str, prepared_by: str, collected_at: str) -> str:
109
+ try:
110
+ date_str = datetime.fromisoformat(collected_at.replace("Z", "+00:00")).strftime("%B %d, %Y")
111
+ except (ValueError, AttributeError):
112
+ date_str = collected_at
113
+ return f"""
114
+ <div class="page-header" style="background: var(--slate-900, #0f1419); color: white;
115
+ padding: 2.5rem; border-radius: 8px; margin-bottom: 2rem;">
116
+ <h1 style="font-family: 'Lora', serif; font-size: 1.8rem; margin-bottom: 0.25rem;">
117
+ {html_mod.escape(brand)}</h1>
118
+ <p style="font-size: 1.1rem; opacity: 0.85; margin-bottom: 1rem;">
119
+ Webex Calling Org Health Assessment</p>
120
+ <p style="font-size: 0.85rem; opacity: 0.6;">
121
+ {date_str} &middot; Prepared by {html_mod.escape(prepared_by)}</p>
122
+ </div>"""
123
+
124
+
125
+ def _org_overview(stats: OrgStats) -> str:
126
+ cards = [
127
+ ("Users", stats.total_users),
128
+ ("Devices", stats.total_devices),
129
+ ("Locations", stats.total_locations),
130
+ ("Features", stats.total_auto_attendants + stats.total_call_queues + stats.total_hunt_groups),
131
+ ]
132
+ items = []
133
+ for label, value in cards:
134
+ items.append(f"""<div class="stat-card" style="flex:1; min-width:120px;">
135
+ <div style="font-size:1.8rem; font-weight:700; font-family:'IBM Plex Mono',monospace;">
136
+ {value}</div>
137
+ <div style="font-size:0.8rem; text-transform:uppercase; letter-spacing:0.05em;
138
+ color:var(--slate-500,#8e97a5);">{label}</div>
139
+ </div>""")
140
+ return f'<div class="stat-strip">{"".join(items)}</div>'
141
+
142
+
143
+ def _summary_cards(categories: dict[str, CategoryScore]) -> str:
144
+ cards = []
145
+ for cat_key in CATEGORY_ORDER:
146
+ cat = categories.get(cat_key)
147
+ if not cat:
148
+ continue
149
+ total = cat.high_count + cat.medium_count + cat.low_count + cat.info_count
150
+ icon = CATEGORY_ICONS.get(cat_key, "")
151
+ if total == 0:
152
+ badge_html = '<span style="color:var(--primary,#00897B);font-weight:600;">✓ No issues found</span>'
153
+ else:
154
+ badges = []
155
+ if cat.high_count:
156
+ badges.append(f'<span class="badge badge-high">{cat.high_count} HIGH</span>')
157
+ if cat.medium_count:
158
+ badges.append(f'<span class="badge badge-medium">{cat.medium_count} MEDIUM</span>')
159
+ if cat.low_count:
160
+ badges.append(f'<span class="badge badge-low">{cat.low_count} LOW</span>')
161
+ if cat.info_count:
162
+ badges.append(f'<span class="badge badge-info">{cat.info_count} INFO</span>')
163
+ badge_html = " ".join(badges)
164
+ cards.append(f"""<div class="summary-card">
165
+ <h3>{icon} {html_mod.escape(cat.display_name)}</h3>
166
+ {badge_html}
167
+ </div>""")
168
+ return f'<div class="summary-grid">{"".join(cards)}</div>'
169
+
170
+
171
+ def _category_section(cat: CategoryScore) -> str:
172
+ total = cat.high_count + cat.medium_count + cat.low_count + cat.info_count
173
+ icon = CATEGORY_ICONS.get(cat.category, "")
174
+ parts = [f'<h2 style="margin-top:2.5rem;">{icon} {html_mod.escape(cat.display_name)}</h2>']
175
+
176
+ if total == 0:
177
+ parts.append('<div class="checkmark-box"><div class="check">✓</div><p>No issues found</p></div>')
178
+ return "\n".join(parts)
179
+
180
+ chart_items = []
181
+ for sev in SEVERITY_ORDER:
182
+ count = getattr(cat, f"{sev.lower()}_count", 0)
183
+ if count > 0:
184
+ chart_items.append({"label": sev, "value": count, "color": SEVERITY_COLORS[sev]})
185
+ if chart_items:
186
+ parts.append(horizontal_bar_chart(chart_items))
187
+
188
+ sorted_findings = sorted(
189
+ cat.findings,
190
+ key=lambda f: SEVERITY_ORDER.index(f.severity) if f.severity in SEVERITY_ORDER else len(SEVERITY_ORDER),
191
+ )
192
+ for finding in sorted_findings:
193
+ parts.append(_render_finding(finding))
194
+
195
+ return "\n".join(parts)
196
+
197
+
198
+ def _render_finding(finding: Finding) -> str:
199
+ sev_class = f"severity-{finding.severity.lower()}"
200
+ sev_badge = f'<span class="badge badge-{finding.severity.lower()}">{finding.severity}</span>'
201
+
202
+ parts = [f'<div class="finding-card {sev_class}">']
203
+ parts.append(f'<div class="finding-title">{sev_badge} {html_mod.escape(finding.title)}</div>')
204
+ parts.append(f'<div class="finding-detail">{html_mod.escape(finding.detail)}</div>')
205
+
206
+ if finding.affected_items:
207
+ parts.append(_render_affected_table(finding.affected_items))
208
+
209
+ parts.append(f'<div class="recommendation"><strong>Recommendation:</strong> {html_mod.escape(finding.recommendation)}</div>')
210
+ parts.append("</div>")
211
+ return "\n".join(parts)
212
+
213
+
214
+ def _render_affected_table(items: list[dict]) -> str:
215
+ if not items:
216
+ return ""
217
+ columns = list(items[0].keys())
218
+ header = "".join(f"<th>{html_mod.escape(c)}</th>" for c in columns)
219
+ rows = []
220
+ for item in items[:20]: # cap at 20 rows
221
+ cells = "".join(f"<td>{html_mod.escape(str(item.get(c, '')))}</td>" for c in columns)
222
+ rows.append(f"<tr>{cells}</tr>")
223
+ overflow = ""
224
+ if len(items) > 20:
225
+ overflow = f'<tr><td colspan="{len(columns)}" style="text-align:center;color:var(--slate-500);">... and {len(items) - 20} more</td></tr>'
226
+ return f'<table class="affected-table"><thead><tr>{header}</tr></thead><tbody>{"".join(rows)}{overflow}</tbody></table>'
227
+
228
+
229
+ def _footer(result: HealthResult) -> str:
230
+ sample_note = ""
231
+ if result.stats.sampled_users_for_permissions > 0:
232
+ sample_note = f"<p>Outgoing permissions sampled from {result.stats.sampled_users_for_permissions} users.</p>"
233
+ return f"""
234
+ <hr style="margin-top:3rem; border:none; border-top:1px solid var(--warm-200,#f0dcc6);">
235
+ <footer style="padding:1.5rem 0; font-size:0.8rem; color:var(--slate-500,#8e97a5);">
236
+ <p>Generated {result.collected_at}</p>
237
+ {sample_note}
238
+ <p>This report is a point-in-time snapshot. Configuration changes after collection are not reflected.</p>
239
+ </footer>"""
240
+
241
+
242
+ def main() -> int:
243
+ parser = argparse.ArgumentParser(description="Generate org health HTML report")
244
+ parser.add_argument("results_dir", type=Path, help="Path to results/ directory")
245
+ parser.add_argument("--brand", required=True, help="Customer/org brand name")
246
+ parser.add_argument("--prepared-by", required=True, help="Name of person generating report")
247
+ args = parser.parse_args()
248
+
249
+ results_path = args.results_dir / "results.json"
250
+ if not results_path.exists():
251
+ print(f"ERROR: results.json not found in {args.results_dir}", file=sys.stderr)
252
+ return 1
253
+
254
+ data = json.loads(results_path.read_text())
255
+ result = _deserialize_result(data)
256
+
257
+ report_dir = args.results_dir.parent / "report"
258
+ report_dir.mkdir(parents=True, exist_ok=True)
259
+ report_path = report_dir / "org-health-report.html"
260
+
261
+ html = generate_report(result, brand=args.brand, prepared_by=args.prepared_by)
262
+ report_path.write_text(html)
263
+ print(f"Report written to {report_path}")
264
+ return 0
265
+
266
+
267
+ def _deserialize_result(data: dict) -> HealthResult:
268
+ findings = [
269
+ Finding(**{k: v for k, v in f.items()})
270
+ for f in data.get("findings", [])
271
+ ]
272
+ categories = {}
273
+ for cat_key, cat_data in data.get("categories", {}).items():
274
+ cat_findings = [
275
+ Finding(**{k: v for k, v in f.items()})
276
+ for f in cat_data.get("findings", [])
277
+ ]
278
+ categories[cat_key] = CategoryScore(
279
+ category=cat_data["category"],
280
+ display_name=cat_data["display_name"],
281
+ high_count=cat_data["high_count"],
282
+ medium_count=cat_data["medium_count"],
283
+ low_count=cat_data["low_count"],
284
+ info_count=cat_data["info_count"],
285
+ findings=cat_findings,
286
+ )
287
+ stats_data = data.get("stats", {})
288
+ stats = OrgStats(**{k: v for k, v in stats_data.items()})
289
+ return HealthResult(
290
+ org_name=data["org_name"],
291
+ org_id=data["org_id"],
292
+ collected_at=data["collected_at"],
293
+ categories=categories,
294
+ findings=findings,
295
+ stats=stats,
296
+ )
297
+
298
+
299
+ if __name__ == "__main__":
300
+ sys.exit(main())
wxcli/output.py ADDED
@@ -0,0 +1,101 @@
1
+ import json
2
+ import re
3
+ from typing import Any
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ console = Console()
8
+
9
+ def format_as_json(data: Any) -> str:
10
+ if hasattr(data, "model_dump"):
11
+ return json.dumps(data.model_dump(by_alias=True), indent=2, default=str)
12
+ if isinstance(data, list):
13
+ items = []
14
+ for item in data:
15
+ if hasattr(item, "model_dump"):
16
+ items.append(item.model_dump(by_alias=True))
17
+ elif isinstance(item, dict):
18
+ items.append(item)
19
+ else:
20
+ items.append(str(item))
21
+ return json.dumps(items, indent=2, default=str)
22
+ return json.dumps(data, indent=2, default=str)
23
+
24
+
25
+ def _auto_detect_columns(item: dict) -> list[tuple[str, str]]:
26
+ """Derive table columns from a dict's keys, skipping nested dicts/lists."""
27
+ cols = []
28
+ for key, val in item.items():
29
+ if isinstance(val, (dict, list)):
30
+ continue
31
+ if not key:
32
+ continue
33
+ # Title-case the camelCase key for the header
34
+ header = key[0].upper() + key[1:]
35
+ # Insert space before uppercase letters: "callType" -> "Call Type"
36
+ header = re.sub(r'(?<=[a-z])(?=[A-Z])', ' ', header)
37
+ cols.append((header, key))
38
+ return cols if cols else [("Value", "")]
39
+
40
+
41
+ def print_table(data: list, columns: list[tuple[str, str]], limit: int = 50) -> None:
42
+ """Print data as a Rich table.
43
+
44
+ columns: list of (header_name, accessor) tuples.
45
+ accessor can use dot notation for nested attrs e.g. "address.city"
46
+ If the configured columns all resolve to empty on the first item,
47
+ auto-detect columns from the item's keys.
48
+ """
49
+ items = data[:limit] if limit > 0 else data
50
+
51
+ # Auto-detect columns if defaults produce all-empty values
52
+ if items and isinstance(items[0], dict):
53
+ first_row = [_resolve_accessor(items[0], acc) for _, acc in columns]
54
+ if all(val is None or val == "" for val in first_row):
55
+ columns = _auto_detect_columns(items[0])
56
+
57
+ table = Table(show_header=True, header_style="bold")
58
+ for header, _ in columns:
59
+ table.add_column(header)
60
+
61
+ for item in items:
62
+ row = []
63
+ for _, accessor in columns:
64
+ val = _resolve_accessor(item, accessor)
65
+ row.append(str(val) if val is not None else "")
66
+ table.add_row(*row)
67
+
68
+ if limit > 0 and len(data) > limit:
69
+ table.add_row(*[f"... {len(data) - limit} more" if i == 0 else "" for i in range(len(columns))])
70
+
71
+ console.print(table)
72
+
73
+
74
+ def print_json(data: Any) -> None:
75
+ """Print data as formatted JSON."""
76
+ print(format_as_json(data))
77
+
78
+
79
+ def _resolve_accessor(obj: Any, accessor: str) -> Any:
80
+ """Resolve a dot-notation accessor on an object or dict.
81
+
82
+ Examples:
83
+ _resolve_accessor(loc, "name") -> loc.name
84
+ _resolve_accessor(loc, "address.city") -> loc.address.city
85
+ _resolve_accessor({"name": "x"}, "name") -> "x"
86
+ """
87
+ parts = accessor.split(".")
88
+ current = obj
89
+ for part in parts:
90
+ if current is None:
91
+ return None
92
+ if isinstance(current, dict):
93
+ current = current.get(part)
94
+ elif hasattr(current, part):
95
+ current = getattr(current, part)
96
+ else:
97
+ return None
98
+ # Handle list types (e.g., emails) — return first element
99
+ if isinstance(current, list) and len(current) > 0:
100
+ return current[0]
101
+ return current