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,106 @@
1
+ """Pattern converter: CUCM route pattern -> Webex dial plan pattern.
2
+
3
+ Converts CUCM dial pattern syntax to Webex-compatible E.164 format patterns.
4
+ Used by routing_mapper and css_mapper for pattern syntax conversion.
5
+
6
+ (from 03b-transform-mappers.md §6, Pattern Syntax Conversion table)
7
+ (from 03b-transform-mappers.md §13, Pattern Converter shared utility)
8
+
9
+ Conversion rules:
10
+ - Strip access code prefix (everything before '.')
11
+ - Prepend country code prefix (e.g., "+1") to the remaining pattern
12
+ - Preserve wildcards: X, !, [ranges]
13
+ - Strip '.' separator entirely
14
+ - International access code (e.g., "011") is stripped when followed by wildcard
15
+ - '@' macro requires expansion (not handled here — see migration config)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+
21
+ def cucm_to_webex_pattern(
22
+ cucm_pattern: str,
23
+ country_code: str = "+1",
24
+ outside_dial_digit: str = "9",
25
+ ) -> str:
26
+ """Convert a CUCM route pattern to a Webex dial plan pattern.
27
+
28
+ (from 03b-transform-mappers.md §13: Pattern Converter)
29
+
30
+ Args:
31
+ cucm_pattern: CUCM route pattern (e.g., "9.1[2-9]XXXXXXXXX", "9.011!")
32
+ country_code: E.164 country prefix including '+' (e.g., "+1", "+44")
33
+ outside_dial_digit: The CUCM outside access digit (e.g., "9")
34
+
35
+ Returns:
36
+ Webex-format dial plan pattern (e.g., "+1[2-9]XXXXXXXXX", "+!")
37
+
38
+ Examples:
39
+ >>> cucm_to_webex_pattern("9.1[2-9]XXXXXXXXX", "+1", "9")
40
+ '+1[2-9]XXXXXXXXX'
41
+ >>> cucm_to_webex_pattern("9.011!", "+1", "9")
42
+ '+!'
43
+ >>> cucm_to_webex_pattern("1XXX", "+1", "9")
44
+ '+11XXX'
45
+ >>> cucm_to_webex_pattern("\\\\+1XXXXXXXXXX", "+1", "9")
46
+ '+1XXXXXXXXXX'
47
+ """
48
+ if not cucm_pattern:
49
+ return cucm_pattern
50
+
51
+ # Coerce outside_dial_digit to str (config may supply it as int, e.g. 9)
52
+ outside_dial_digit = str(outside_dial_digit)
53
+
54
+ # Strip leading backslash-escaped '+' (CUCM uses \+ for literal plus)
55
+ # (CUCM route patterns sometimes escape + as \\+)
56
+ working = cucm_pattern.replace("\\+", "+")
57
+
58
+ # If pattern already starts with '+', it's already E.164 format
59
+ if working.startswith("+"):
60
+ return working
61
+
62
+ # Track whether we stripped an access code. When an access code is present,
63
+ # the remainder typically starts with the national prefix (e.g., "1" for US).
64
+ # Without an access code, the pattern is a direct route pattern where digits
65
+ # should NOT be interpreted as embedded country codes.
66
+ access_code_stripped = False
67
+
68
+ # Strip access code prefix: everything before '.' (the dot separator)
69
+ # (from 03b-transform-mappers.md §6: "Strips access code prefix (before '.')")
70
+ if "." in working:
71
+ _prefix, remainder = working.split(".", 1)
72
+ working = remainder
73
+ access_code_stripped = True
74
+ # If no dot but starts with outside_dial_digit followed by known patterns,
75
+ # strip the leading access code
76
+ elif working.startswith(outside_dial_digit) and len(working) > 1:
77
+ next_char = working[len(outside_dial_digit):]
78
+ if next_char and (next_char[0].isdigit() or next_char[0] in "X[!"):
79
+ working = working[len(outside_dial_digit):]
80
+ access_code_stripped = True
81
+
82
+ # Handle international access code patterns (e.g., "011!")
83
+ # (from 03b-transform-mappers.md §6: "9.011!" -> "+!")
84
+ # Common international access codes: 011 (US/CA), 00 (many countries)
85
+ intl_prefixes = ["011", "00"]
86
+ for intl in intl_prefixes:
87
+ if working.startswith(intl):
88
+ remainder_after_intl = working[len(intl):]
89
+ if remainder_after_intl:
90
+ return f"+{remainder_after_intl}"
91
+ else:
92
+ return f"+{working}"
93
+
94
+ # If we stripped an access code, the remainder starts with the national
95
+ # prefix digits (e.g., "1" for "+1"). Just prepend "+".
96
+ # (from 03b-transform-mappers.md §6: "Strip access code prefix, prepend + and country code")
97
+ # Example: "9.1[2-9]XXXXXXXXX" -> strip "9." -> "1[2-9]XXXXXXXXX" -> "+1[2-9]XXXXXXXXX"
98
+ if access_code_stripped:
99
+ bare_cc = country_code.lstrip("+")
100
+ if bare_cc and working.startswith(bare_cc):
101
+ return f"+{working}"
102
+
103
+ # Prepend full country code prefix for patterns without embedded national prefix
104
+ # (from 03b-transform-mappers.md §6: "prepend + and country code")
105
+ # country_code already includes '+' (e.g., "+1")
106
+ return f"{country_code}{working}"
@@ -0,0 +1,271 @@
1
+ """Normalization pipeline: DiscoveryResult → normalized store → cross-references.
2
+
3
+ This is the Phase 04 entry point. It connects Phase 03 extraction output
4
+ (DiscoveryResult.raw_data) to the normalizers and CrossReferenceBuilder.
5
+
6
+ Pipeline steps:
7
+ 1. Iterate raw_data using RAW_DATA_MAPPING to call the right normalizer per item
8
+ 2. Classify common-area phones as workspaces (post-normalization)
9
+ 3. Normalize Unity Connection per-user VM settings (if present)
10
+ 4. Upsert all normalized objects to the MigrationStore
11
+ 5. Run CrossReferenceBuilder (pass 2)
12
+
13
+ (from 02-normalization-architecture.md, two-pass ELT design)
14
+ (from Phase 03 DiscoveryResult.raw_data contract)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import Any
21
+
22
+ from datetime import datetime, timezone
23
+
24
+ from wxcli.migration.models import MigrationObject, MigrationStatus, Provenance
25
+ from wxcli.migration.store import MigrationStore
26
+ from wxcli.migration.transform.cross_reference import CrossReferenceBuilder
27
+ from wxcli.migration.transform.normalizers import (
28
+ NORMALIZER_REGISTRY,
29
+ RAW_DATA_MAPPING,
30
+ is_common_area_device,
31
+ normalize_dect_group,
32
+ normalize_unity_vm_settings,
33
+ normalize_workspace,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ def normalize_discovery(
40
+ raw_data: dict[str, dict[str, Any]],
41
+ store: MigrationStore,
42
+ cluster: str = "default",
43
+ default_country_code: str = "US",
44
+ site_prefix_rules: list[dict] | None = None,
45
+ dect_inventory: list[dict[str, str]] | None = None,
46
+ ) -> dict[str, Any]:
47
+ """Run the full normalization pipeline on discovery raw_data.
48
+
49
+ Args:
50
+ raw_data: DiscoveryResult.raw_data from Phase 03 extraction.
51
+ store: MigrationStore to write normalized objects to.
52
+ cluster: CUCM cluster name for provenance.
53
+ default_country_code: ISO country code for E.164 normalization.
54
+ site_prefix_rules: Prefix stripping rules for E.164 normalization.
55
+ dect_inventory: Optional list of base station dicts from --dect-inventory
56
+ CSV. Each dict has keys: coverage_zone, base_station_mac,
57
+ base_station_model. Passed through to normalize_dect_group() so
58
+ that inventory data is merged into CanonicalDECTNetwork objects.
59
+
60
+ Returns:
61
+ Summary dict with counts per step.
62
+ """
63
+ summary: dict[str, Any] = {"pass1": {}, "pass2": {}}
64
+
65
+ # ------------------------------------------------------------------
66
+ # Pass 1: Normalize all objects from raw_data
67
+ # ------------------------------------------------------------------
68
+
69
+ total_normalized = 0
70
+
71
+ for extractor_key, sub_key, normalizer_key in RAW_DATA_MAPPING:
72
+ normalizer_fn = NORMALIZER_REGISTRY.get(normalizer_key)
73
+ if normalizer_fn is None:
74
+ logger.warning("No normalizer for %s/%s (key: %s)", extractor_key, sub_key, normalizer_key)
75
+ continue
76
+
77
+ extractor_data = raw_data.get(extractor_key, {})
78
+ items = extractor_data.get(sub_key, [])
79
+ if not isinstance(items, list):
80
+ continue
81
+
82
+ count = 0
83
+ for item in items:
84
+ try:
85
+ obj = normalizer_fn(item, cluster=cluster)
86
+ if obj is None:
87
+ continue # Normalizer rejected this item (e.g., empty name)
88
+ store.upsert_object(obj)
89
+ count += 1
90
+ except Exception:
91
+ logger.exception(
92
+ "Failed to normalize %s/%s item: %s",
93
+ extractor_key, sub_key,
94
+ item.get("name", item.get("pattern", item.get("pkid", "?"))),
95
+ )
96
+
97
+ summary["pass1"][f"{extractor_key}/{sub_key}"] = count
98
+ total_normalized += count
99
+ if count > 0:
100
+ logger.info("Normalized %d %s/%s objects", count, extractor_key, sub_key)
101
+
102
+ # ------------------------------------------------------------------
103
+ # Preserve raw phone objects for mappers (§8 fix)
104
+ # normalize_phone() creates CanonicalDevice (object_type="device") but
105
+ # MonitoringMapper, CallForwardingMapper, DeviceLayoutMapper, DeviceMapper,
106
+ # and WorkspaceMapper all call store.get_objects("phone") expecting raw
107
+ # phone dicts with speeddials, busyLampFields, and per-line call forwarding.
108
+ # Store raw phone data as MigrationObject with canonical_id="phone:{name}".
109
+ # ------------------------------------------------------------------
110
+
111
+ phones = raw_data.get("devices", {}).get("phones", [])
112
+ raw_phone_count = 0
113
+ for phone in phones:
114
+ phone_name = phone.get("name", "")
115
+ if not phone_name:
116
+ continue
117
+ raw_obj = MigrationObject(
118
+ canonical_id=f"phone:{phone_name}",
119
+ provenance=Provenance(
120
+ source_system="cucm",
121
+ source_id=phone.get("pkid", ""),
122
+ source_name=phone_name,
123
+ cluster=cluster,
124
+ extracted_at=datetime.now(timezone.utc),
125
+ ),
126
+ status=MigrationStatus.NORMALIZED,
127
+ pre_migration_state=phone,
128
+ )
129
+ store.upsert_object(raw_obj)
130
+ raw_phone_count += 1
131
+
132
+ summary["pass1"]["raw_phones_preserved"] = raw_phone_count
133
+ if raw_phone_count > 0:
134
+ logger.info("Preserved %d raw phone objects for mappers", raw_phone_count)
135
+
136
+ # ------------------------------------------------------------------
137
+ # Post-normalization: classify common-area phones as workspaces
138
+ # (from 02b §2.8: common-area phones have ownerUserName=None, class=Phone)
139
+ # Skip phones whose description matches a known user — those are
140
+ # user-owned phones that just lack an explicit ownerUserName in CUCM.
141
+ # A user can own multiple phones; the cross-ref builder handles this.
142
+ # ------------------------------------------------------------------
143
+
144
+ # Build a simple user lookup for description matching
145
+ _user_names: dict[str, str] = {} # lowercase key → user canonical_id
146
+ for u in store.get_objects("user"):
147
+ uid = (u.get("cucm_userid") or "").lower()
148
+ fn = (u.get("first_name") or "").lower().strip()
149
+ ln = (u.get("last_name") or "").lower().strip()
150
+ cid = u["canonical_id"]
151
+ if uid:
152
+ _user_names[uid] = cid
153
+ if fn and ln:
154
+ _user_names[f"{fn} {ln}"] = cid
155
+ _user_names[f"{ln}, {fn}"] = cid
156
+ _user_names[f"{ln} {fn}"] = cid
157
+
158
+ def _description_matches_user(desc: str) -> bool:
159
+ if not desc:
160
+ return False
161
+ dl = desc.lower().strip()
162
+ if dl in _user_names:
163
+ return True
164
+ # Substring: check if a full name (2+ words) appears in description
165
+ for key in _user_names:
166
+ if " " in key and len(key) >= 4 and key in dl:
167
+ return True
168
+ return False
169
+
170
+ workspace_count = 0
171
+ inferred_owner_count = 0
172
+ for phone in phones:
173
+ if is_common_area_device(phone):
174
+ desc = phone.get("description", "")
175
+ if _description_matches_user(desc):
176
+ inferred_owner_count += 1
177
+ logger.info(
178
+ "Skipping workspace for %s — description '%s' matches a user",
179
+ phone.get("name", "?"), desc,
180
+ )
181
+ continue
182
+ ws_obj = normalize_workspace(phone, cluster=cluster)
183
+ store.upsert_object(ws_obj)
184
+ workspace_count += 1
185
+
186
+ # Inject is_common_area flag into the stored phone object
187
+ # so WorkspaceMapper and DeviceMapper can detect common-area phones
188
+ phone_name = phone.get("name", "")
189
+ if phone_name:
190
+ phone_obj = store.get_object(f"phone:{phone_name}")
191
+ if phone_obj:
192
+ pms = dict(phone_obj.get("pre_migration_state") or {})
193
+ pms["is_common_area"] = True
194
+ store.upsert_object(MigrationObject(
195
+ canonical_id=f"phone:{phone_name}",
196
+ provenance=Provenance(
197
+ source_system="cucm",
198
+ source_id=phone.get("pkid", ""),
199
+ source_name=phone_name,
200
+ cluster=cluster,
201
+ extracted_at=datetime.now(timezone.utc),
202
+ ),
203
+ status=MigrationStatus.NORMALIZED,
204
+ pre_migration_state=pms,
205
+ ))
206
+
207
+ summary["pass1"]["workspaces_classified"] = workspace_count
208
+ summary["pass1"]["device_owners_inferred"] = inferred_owner_count
209
+ if workspace_count > 0:
210
+ logger.info("Classified %d common-area phones as workspaces", workspace_count)
211
+ if inferred_owner_count > 0:
212
+ logger.info(
213
+ "Inferred user ownership for %d phones from description field",
214
+ inferred_owner_count,
215
+ )
216
+
217
+ # ------------------------------------------------------------------
218
+ # Unity Connection per-user VM settings (if present)
219
+ # (from Phase 03 unity_connection.py extract_user_vm_settings())
220
+ # ------------------------------------------------------------------
221
+
222
+ unity_settings = raw_data.get("voicemail", {}).get("unity_user_settings", {})
223
+ unity_count = 0
224
+ if isinstance(unity_settings, dict):
225
+ for userid, settings in unity_settings.items():
226
+ if isinstance(settings, dict):
227
+ try:
228
+ obj = normalize_unity_vm_settings(userid, settings, cluster=cluster)
229
+ store.upsert_object(obj)
230
+ unity_count += 1
231
+ except Exception:
232
+ logger.exception("Failed to normalize Unity VM settings for %s", userid)
233
+
234
+ summary["pass1"]["unity_vm_settings"] = unity_count
235
+ if unity_count > 0:
236
+ logger.info("Normalized %d Unity Connection per-user VM settings", unity_count)
237
+
238
+ summary["pass1"]["total"] = total_normalized + workspace_count + unity_count
239
+
240
+ # ------------------------------------------------------------------
241
+ # Pass 2: CrossReferenceBuilder
242
+ # ------------------------------------------------------------------
243
+
244
+ builder = CrossReferenceBuilder(
245
+ store,
246
+ default_country_code=default_country_code,
247
+ site_prefix_rules=site_prefix_rules or [],
248
+ )
249
+ pass2_counts = builder.build()
250
+ summary["pass2"] = pass2_counts
251
+
252
+ # ------------------------------------------------------------------
253
+ # Post-normalization: DECT network grouping
254
+ # Runs after Pass 2 so compatibility_tier has been classified on all devices.
255
+ # Groups DECT handsets by device pool into CanonicalDECTNetwork objects.
256
+ # ------------------------------------------------------------------
257
+
258
+ dect_networks = normalize_dect_group(store, dect_inventory=dect_inventory)
259
+ summary["pass1"]["dect_networks_grouped"] = len(dect_networks)
260
+ if dect_networks:
261
+ logger.info("Grouped %d DECT handsets into %d DECT network(s)", sum(
262
+ len(n.handset_assignments) for n in dect_networks
263
+ ), len(dect_networks))
264
+
265
+ logger.info(
266
+ "Normalization complete: %d objects normalized, %d cross-refs built",
267
+ summary["pass1"]["total"],
268
+ sum(v for v in pass2_counts.values() if isinstance(v, int)),
269
+ )
270
+
271
+ return summary
@@ -0,0 +1,255 @@
1
+ """Auto-resolution rules engine for migration decisions.
2
+
3
+ Applies configurable rules to automatically resolve pending decisions
4
+ without requiring manual user input. Rules are driven by the config
5
+ dict passed to apply_auto_rules().
6
+
7
+ (from 03-conflict-detection-engine.md, Auto-Resolution Rules)
8
+ (from 03b-transform-mappers.md section 13, TransformEngine integration)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Any
15
+
16
+ from wxcli.migration.store import MigrationStore
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def _match_rule(rule: dict[str, Any], decision: dict[str, Any]) -> bool:
22
+ """Check if a rule's ``match`` field matches a decision's context.
23
+
24
+ Supports declarative matching only — no eval/exec.
25
+
26
+ Match field format (from 03-conflict-detection-engine.md, Auto-Resolution Rules):
27
+ - Plain key: exact match, or membership if value is a list
28
+ ``{"cucm_model": ["7841", "7861"]}`` → context["cucm_model"] in list
29
+ - ``_lte`` suffix: less-than-or-equal comparison
30
+ ``{"dn_length_lte": 4}`` → context["dn_length"] <= 4
31
+ - ``_gte`` suffix: greater-than-or-equal comparison
32
+ - ``_contains`` suffix: substring match
33
+ ``{"name_contains": "lobby"}`` → "lobby" in context["name"]
34
+
35
+ All match fields use AND logic — every field must match.
36
+ If a match key is not present in the decision's context, the rule is skipped.
37
+ Non-numeric context values with _lte/_gte operators are skipped (no crash).
38
+ """
39
+ match = rule.get("match")
40
+ if not match or not isinstance(match, dict):
41
+ return True # No match field → type-only rule, always matches
42
+
43
+ context = decision.get("context", {})
44
+
45
+ for key, expected in match.items():
46
+ # Determine operator suffix
47
+ if key.endswith("_lte"):
48
+ base_key = key[:-4]
49
+ actual = context.get(base_key)
50
+ if actual is None:
51
+ return False
52
+ try:
53
+ if float(actual) > float(expected):
54
+ return False
55
+ except (ValueError, TypeError):
56
+ return False # Non-numeric → skip gracefully
57
+
58
+ elif key.endswith("_gte"):
59
+ base_key = key[:-4]
60
+ actual = context.get(base_key)
61
+ if actual is None:
62
+ return False
63
+ try:
64
+ if float(actual) < float(expected):
65
+ return False
66
+ except (ValueError, TypeError):
67
+ return False
68
+
69
+ elif key.endswith("_contains"):
70
+ base_key = key[:-9]
71
+ actual = context.get(base_key)
72
+ if actual is None:
73
+ return False
74
+ if not isinstance(actual, str) or not isinstance(expected, str):
75
+ return False
76
+ if expected not in actual:
77
+ return False
78
+
79
+ else:
80
+ # Plain key: exact match or list membership
81
+ actual = context.get(key)
82
+ if actual is None:
83
+ return False
84
+ if isinstance(expected, list):
85
+ if actual not in expected:
86
+ return False
87
+ else:
88
+ if actual != expected:
89
+ return False
90
+
91
+ return True
92
+
93
+
94
+ def _synthesized_reason(dec_type: str, choice: str) -> str:
95
+ """Default reason when a rule has no explicit `reason` field."""
96
+ return f"Auto-rule: {dec_type} → {choice}"
97
+
98
+
99
+ def _resolve_reason(rule: dict[str, Any], dec_type: str, choice: str) -> str:
100
+ """Pick the reason string for a matched rule.
101
+
102
+ Prefer rule['reason'] if it is a non-empty string. Otherwise (missing,
103
+ None, empty, or non-string), fall back to the synthesized form and
104
+ log a warning for the non-string case.
105
+ """
106
+ raw = rule.get("reason")
107
+ if isinstance(raw, str) and raw:
108
+ return raw
109
+ if raw is not None and not isinstance(raw, str):
110
+ logger.warning(
111
+ "auto-rule reason field is not a string (got %r), using synthesized reason",
112
+ type(raw).__name__,
113
+ )
114
+ return _synthesized_reason(dec_type, choice)
115
+
116
+
117
+ def _iter_matching_resolutions(
118
+ store: MigrationStore,
119
+ config: dict[str, Any],
120
+ ):
121
+ """Yield ``(decision, choice, reason)`` for every pending decision that
122
+ a config rule would resolve. Pure read — does NOT mutate the store.
123
+
124
+ Both ``apply_auto_rules`` and ``preview_auto_rules`` consume this
125
+ generator so they cannot drift on matcher semantics.
126
+
127
+ Walks the same rules in the same order as the pre-refactor
128
+ ``apply_auto_rules`` (first-match-wins within a decision type). Skips:
129
+ - already-resolved decisions (``chosen_option is not None``)
130
+ - malformed rules (missing type or choice)
131
+ - rules whose match clause fails
132
+ - rules whose ``choice`` is not in the decision's ``options`` list
133
+ (option-validation parity with ``apply_auto_rules``)
134
+ """
135
+ rules = config.get("auto_rules", [])
136
+ if not rules:
137
+ return
138
+
139
+ valid_rules: list[dict[str, Any]] = []
140
+ for rule in rules:
141
+ if rule.get("type") and rule.get("choice"):
142
+ valid_rules.append(rule)
143
+ else:
144
+ # Surface config typos — a rule missing `type` or `choice` is
145
+ # almost always a typo (e.g., "tpe" or missing quotes). Without
146
+ # this warning, operators see zero auto-applies with no signal
147
+ # about why. The warning includes the rule dict so they can
148
+ # grep config.json for the bad entry.
149
+ logger.warning(
150
+ "Auto-rule missing 'type' or 'choice' — skipping: %r",
151
+ rule,
152
+ )
153
+
154
+ if not valid_rules:
155
+ return
156
+
157
+ all_decisions = store.get_all_decisions()
158
+
159
+ for dec in all_decisions:
160
+ if dec.get("chosen_option") is not None:
161
+ continue
162
+
163
+ dec_type = dec.get("type", "")
164
+
165
+ for rule in valid_rules:
166
+ if rule["type"] != dec_type:
167
+ continue
168
+ if not _match_rule(rule, dec):
169
+ continue
170
+
171
+ choice = rule["choice"]
172
+
173
+ # Option-validation parity with apply_auto_rules.
174
+ options = dec.get("options", [])
175
+ valid_ids = {opt["id"] for opt in options if isinstance(opt, dict)}
176
+ if valid_ids and choice not in valid_ids:
177
+ logger.warning(
178
+ "Auto-rule choice '%s' for decision %s (type=%s) "
179
+ "is not a valid option. Valid: %s. Skipping.",
180
+ choice,
181
+ dec.get("decision_id", ""),
182
+ dec_type,
183
+ valid_ids,
184
+ )
185
+ continue
186
+
187
+ reason = _resolve_reason(rule, dec_type, choice)
188
+ yield dec, choice, reason
189
+ break # First matching rule wins for this decision
190
+
191
+
192
+ def preview_auto_rules(
193
+ store: MigrationStore,
194
+ config: dict[str, Any],
195
+ ) -> list[dict[str, Any]]:
196
+ """Return the list of pending decisions that current config rules would
197
+ resolve, each augmented with ``auto_choice`` and ``auto_reason`` keys.
198
+
199
+ Pure read: does NOT mutate the store. Used by ``classify_decisions``
200
+ and the CLI preview path in ``wxcli cucm decide --apply-auto``.
201
+ """
202
+ result: list[dict[str, Any]] = []
203
+ for dec, choice, reason in _iter_matching_resolutions(store, config):
204
+ d = dict(dec)
205
+ d["auto_choice"] = choice
206
+ d["auto_reason"] = reason
207
+ result.append(d)
208
+ return result
209
+
210
+
211
+ def apply_auto_rules(store: MigrationStore, config: dict[str, Any]) -> int:
212
+ """Apply auto-resolution rules from config to pending decisions.
213
+
214
+ Consumes ``_iter_matching_resolutions`` so apply and preview cannot
215
+ drift on matcher semantics.
216
+
217
+ Config format (from 03-conflict-detection-engine.md, Auto-Resolution Rules)::
218
+
219
+ {"auto_rules": [
220
+ {"type": "DEVICE_INCOMPATIBLE", "choice": "skip"},
221
+ {"type": "DN_AMBIGUOUS",
222
+ "match": {"dn_length_lte": 4},
223
+ "choice": "extension_only"},
224
+ ]}
225
+
226
+ Match field supports plain values (exact/list membership), ``_lte``/
227
+ ``_gte``/``_contains`` suffixes, and AND logic across multiple fields.
228
+
229
+ Each resolved decision gets::
230
+
231
+ chosen_option = <choice from rule>
232
+ resolved_by = "auto_rule"
233
+ resolved_at = current UTC timestamp
234
+
235
+ Returns count of decisions auto-resolved.
236
+
237
+ (from 03-conflict-detection-engine.md, Auto-Resolution Rules)
238
+ """
239
+ resolved_count = 0
240
+ for dec, choice, _reason in _iter_matching_resolutions(store, config):
241
+ store.resolve_decision(
242
+ decision_id=dec["decision_id"],
243
+ chosen_option=choice,
244
+ resolved_by="auto_rule",
245
+ )
246
+ resolved_count += 1
247
+ logger.debug(
248
+ "Auto-resolved decision %s (type=%s) with choice='%s'",
249
+ dec.get("decision_id", ""),
250
+ dec.get("type", ""),
251
+ choice,
252
+ )
253
+
254
+ logger.info("Auto-rules resolved %d decision(s)", resolved_count)
255
+ return resolved_count