hap-cli 0.8.18__tar.gz → 0.8.20__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. {hap_cli-0.8.18 → hap_cli-0.8.20}/PKG-INFO +1 -1
  2. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/__init__.py +1 -1
  3. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/record.py +131 -22
  4. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/session.py +24 -13
  5. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/worksheet.py +19 -4
  6. hap_cli-0.8.20/hap_cli/tests/test_control_lookup_guard.py +179 -0
  7. hap_cli-0.8.20/hap_cli/tests/test_member_relation_array_wrap.py +103 -0
  8. hap_cli-0.8.20/hap_cli/tests/test_workflow_envelope_errors.py +112 -0
  9. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli.egg-info/PKG-INFO +1 -1
  10. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli.egg-info/SOURCES.txt +3 -0
  11. {hap_cli-0.8.18 → hap_cli-0.8.20}/MANIFEST.in +0 -0
  12. {hap_cli-0.8.18 → hap_cli-0.8.20}/README.md +0 -0
  13. {hap_cli-0.8.18 → hap_cli-0.8.20}/app_live_tests/__init__.py +0 -0
  14. {hap_cli-0.8.18 → hap_cli-0.8.20}/app_live_tests/__main__.py +0 -0
  15. {hap_cli-0.8.18 → hap_cli-0.8.20}/app_live_tests/config.py +0 -0
  16. {hap_cli-0.8.18 → hap_cli-0.8.20}/app_live_tests/harness.py +0 -0
  17. {hap_cli-0.8.18 → hap_cli-0.8.20}/app_live_tests/smoke.py +0 -0
  18. {hap_cli-0.8.18 → hap_cli-0.8.20}/app_live_tests/state.py +0 -0
  19. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/README.md +0 -0
  20. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/README_CN.md +0 -0
  21. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/__main__.py +0 -0
  22. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/__init__.py +0 -0
  23. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/app_cmd.py +0 -0
  24. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/app_creator_cmd.py +0 -0
  25. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/app_editor_cmd.py +0 -0
  26. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/auth_cmd.py +0 -0
  27. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/calendar_cmd.py +0 -0
  28. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/chart_cmd.py +0 -0
  29. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/chat_cmd.py +0 -0
  30. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/chatbot_cmd.py +0 -0
  31. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/config_cmd.py +0 -0
  32. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/contact_cmd.py +0 -0
  33. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/department_cmd.py +0 -0
  34. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/group_cmd.py +0 -0
  35. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/icon_cmd.py +0 -0
  36. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/instance_cmd.py +0 -0
  37. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/node_cmd.py +0 -0
  38. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/optionset_cmd.py +0 -0
  39. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/page_cmd.py +0 -0
  40. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/post_cmd.py +0 -0
  41. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/record_cmd.py +0 -0
  42. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/region_cmd.py +0 -0
  43. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/role_cmd.py +0 -0
  44. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/update_cmd.py +0 -0
  45. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/upload_cmd.py +0 -0
  46. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/v3_registry.py +0 -0
  47. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/workflow_cmd.py +0 -0
  48. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/commands/worksheet_cmd.py +0 -0
  49. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/context.py +0 -0
  50. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/__init__.py +0 -0
  51. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/action_spec_adapter.py +0 -0
  52. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app.py +0 -0
  53. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/__init__.py +0 -0
  54. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/_hapmeta/__init__.py +0 -0
  55. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/_hapmeta/control_type_codes.py +0 -0
  56. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/_hapmeta/worksheet_templates.py +0 -0
  57. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/charts.py +0 -0
  58. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/cleanup.py +0 -0
  59. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/compiler.py +0 -0
  60. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/config.py +0 -0
  61. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/design/design.schema.json +0 -0
  62. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/errors.py +0 -0
  63. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/examples/minimal.design.json +0 -0
  64. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/executor.py +0 -0
  65. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/fields.py +0 -0
  66. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/hap.py +0 -0
  67. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/merge.py +0 -0
  68. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/recording/__init__.py +0 -0
  69. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/recording/console.py +0 -0
  70. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/recording/jsonl.py +0 -0
  71. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/recording/mirror.py +0 -0
  72. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/recording/report.py +0 -0
  73. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/schema.py +0 -0
  74. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/seed/INSTRUCTIONS.md +0 -0
  75. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/seed/__init__.py +0 -0
  76. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/seed/cli.py +0 -0
  77. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/seed/executor.py +0 -0
  78. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/seed/resources/attachments.json +0 -0
  79. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/seed/resources/sample.docx +0 -0
  80. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/seed/resources/sample.pdf +0 -0
  81. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/seed/resources/sample_images.json +0 -0
  82. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/seed/template.py +0 -0
  83. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/selftest.py +0 -0
  84. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/smoke.py +0 -0
  85. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/steps.py +0 -0
  86. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/store.py +0 -0
  87. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/validate.py +0 -0
  88. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/app_creator/workflow_dsl.py +0 -0
  89. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/auth.py +0 -0
  90. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/calendar_mod.py +0 -0
  91. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/chart.py +0 -0
  92. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/chat.py +0 -0
  93. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/chatbot.py +0 -0
  94. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/cli_access.py +0 -0
  95. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/contact.py +0 -0
  96. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/control_type_codes.py +0 -0
  97. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/department.py +0 -0
  98. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/editor/__init__.py +0 -0
  99. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/editor/_hapmeta/__init__.py +0 -0
  100. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/editor/_hapmeta/control_type_codes.py +0 -0
  101. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/editor/apply.py +0 -0
  102. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/editor/componentlower.py +0 -0
  103. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/editor/editspec.py +0 -0
  104. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/editor/errors.py +0 -0
  105. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/editor/fieldlower.py +0 -0
  106. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/editor/jsonschema_mini.py +0 -0
  107. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/editor/models.py +0 -0
  108. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/editor/ops.py +0 -0
  109. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/editor/planner.py +0 -0
  110. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/editor/reader.py +0 -0
  111. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/field_normalizer.py +0 -0
  112. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/field_spec_adapter.py +0 -0
  113. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/filter_translator.py +0 -0
  114. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/flow_node.py +0 -0
  115. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/global_meta.py +0 -0
  116. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/group.py +0 -0
  117. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/icon_index.py +0 -0
  118. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/instance.py +0 -0
  119. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/logger.py +0 -0
  120. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/optionset.py +0 -0
  121. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/page.py +0 -0
  122. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/post.py +0 -0
  123. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/response_crypto.py +0 -0
  124. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/role.py +0 -0
  125. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/role_perm_builder.py +0 -0
  126. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/token_crypto.py +0 -0
  127. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/update.py +0 -0
  128. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/upload.py +0 -0
  129. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/v3/__init__.py +0 -0
  130. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/v3/dispatcher.py +0 -0
  131. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/v3/render.py +0 -0
  132. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/v3/schema.py +0 -0
  133. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/view_spec_adapter.py +0 -0
  134. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/workflow.py +0 -0
  135. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/workflow_node_dsl.py +0 -0
  136. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/core/worksheet_templates.py +0 -0
  137. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/hap_cli.py +0 -0
  138. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/i18n.py +0 -0
  139. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/locale/__init__.py +0 -0
  140. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/locale/messages.json +0 -0
  141. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/locale/messages.schema.json +0 -0
  142. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/__init__.py +0 -0
  143. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/conftest.py +0 -0
  144. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_app_creator.py +0 -0
  145. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_app_editor.py +0 -0
  146. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_app_export_usage.py +0 -0
  147. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_auth_prerelease.py +0 -0
  148. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_bug_036430.py +0 -0
  149. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_bug_036750.py +0 -0
  150. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_chart.py +0 -0
  151. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_cli_access.py +0 -0
  152. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_config_cmd.py +0 -0
  153. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_core.py +0 -0
  154. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_destructive_confirm_cli.py +0 -0
  155. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_full_e2e.py +0 -0
  156. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_global_meta.py +0 -0
  157. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_i18n.py +0 -0
  158. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_instance_history_cli.py +0 -0
  159. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_integration.py +0 -0
  160. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_integration_approval.py +0 -0
  161. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_integration_approval_actions.py +0 -0
  162. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_integration_calendar.py +0 -0
  163. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_integration_destructive.py +0 -0
  164. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_integration_misc.py +0 -0
  165. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_integration_post.py +0 -0
  166. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_integration_social.py +0 -0
  167. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_integration_v3.py +0 -0
  168. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_integration_workflow.py +0 -0
  169. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_integration_worksheet_extra.py +0 -0
  170. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_multi_profile.py +0 -0
  171. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_org_approval_v3_semantic_cli.py +0 -0
  172. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_org_id_cli.py +0 -0
  173. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_org_id_docs.py +0 -0
  174. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_parameter_conventions.py +0 -0
  175. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_parameter_mapping_registry.py +0 -0
  176. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_record_batch_v3_semantic_cli.py +0 -0
  177. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_record_field_alias.py +0 -0
  178. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_record_v3_semantic_cli.py +0 -0
  179. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_select_rejects_relation_value.py +0 -0
  180. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_semantic_types_registry.py +0 -0
  181. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_update.py +0 -0
  182. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_v3_api_schema_loader.py +0 -0
  183. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_v3_dispatcher.py +0 -0
  184. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_v3_registry_coverage.py +0 -0
  185. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_v3_session.py +0 -0
  186. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_v3_yaml_translation.py +0 -0
  187. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_workflow_list_cli.py +0 -0
  188. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_workflow_v3_semantic_cli.py +0 -0
  189. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_worksheet_crud_cli.py +0 -0
  190. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/tests/test_worksheet_v3_semantic_cli.py +0 -0
  191. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/utils/__init__.py +0 -0
  192. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/utils/formatting.py +0 -0
  193. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/utils/options.py +0 -0
  194. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli/utils/parameter_mapping.py +0 -0
  195. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli.egg-info/dependency_links.txt +0 -0
  196. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli.egg-info/entry_points.txt +0 -0
  197. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli.egg-info/requires.txt +0 -0
  198. {hap_cli-0.8.18 → hap_cli-0.8.20}/hap_cli.egg-info/top_level.txt +0 -0
  199. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/__init__.py +0 -0
  200. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/__main__.py +0 -0
  201. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/_wftest.py +0 -0
  202. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/charts.py +0 -0
  203. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/cleanup.py +0 -0
  204. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/compiler.py +0 -0
  205. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/config.py +0 -0
  206. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/errors.py +0 -0
  207. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/executor.py +0 -0
  208. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/fields.py +0 -0
  209. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/hap.py +0 -0
  210. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/recording/__init__.py +0 -0
  211. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/recording/console.py +0 -0
  212. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/recording/jsonl.py +0 -0
  213. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/recording/mirror.py +0 -0
  214. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/recording/report.py +0 -0
  215. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/schema.py +0 -0
  216. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/seed/__init__.py +0 -0
  217. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/seed/cli.py +0 -0
  218. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/seed/executor.py +0 -0
  219. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/seed/template.py +0 -0
  220. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/selftest.py +0 -0
  221. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/smoke.py +0 -0
  222. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/step.py +0 -0
  223. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/steps.py +0 -0
  224. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/store.py +0 -0
  225. {hap_cli-0.8.18 → hap_cli-0.8.20}/live_tests/workflow_dsl.py +0 -0
  226. {hap_cli-0.8.18 → hap_cli-0.8.20}/setup.cfg +0 -0
  227. {hap_cli-0.8.18 → hap_cli-0.8.20}/setup.py +0 -0
  228. {hap_cli-0.8.18 → hap_cli-0.8.20}/skills/cli/hap-cli-app-editor/scripts/editspec/component.schema.json +0 -0
  229. {hap_cli-0.8.18 → hap_cli-0.8.20}/skills/cli/hap-cli-app-editor/scripts/editspec/custom-action.schema.json +0 -0
  230. {hap_cli-0.8.18 → hap_cli-0.8.20}/skills/cli/hap-cli-app-editor/scripts/editspec/envelope.schema.json +0 -0
  231. {hap_cli-0.8.18 → hap_cli-0.8.20}/skills/cli/hap-cli-app-editor/scripts/editspec/field.schema.json +0 -0
  232. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/INDEX.json +0 -0
  233. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/add_member_to_role.yaml +0 -0
  234. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/batch_delete_records.yaml +0 -0
  235. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/create_optionset.yaml +0 -0
  236. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/create_role.yaml +0 -0
  237. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/delete_optionset.yaml +0 -0
  238. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/delete_record.yaml +0 -0
  239. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/delete_role.yaml +0 -0
  240. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/delete_worksheet.yaml +0 -0
  241. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_app_info.yaml +0 -0
  242. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_app_knowledge_list.yaml +0 -0
  243. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_app_worksheets_list.yaml +0 -0
  244. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_optionset_list.yaml +0 -0
  245. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_record_details.yaml +0 -0
  246. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_record_discussions.yaml +0 -0
  247. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_record_list.yaml +0 -0
  248. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_record_logs.yaml +0 -0
  249. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_record_pivot_data.yaml +0 -0
  250. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_record_relations.yaml +0 -0
  251. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_record_share_link.yaml +0 -0
  252. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_regions.yaml +0 -0
  253. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_role_details.yaml +0 -0
  254. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/get_role_list.yaml +0 -0
  255. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/knowledge_search.yaml +0 -0
  256. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/leave_all_roles.yaml +0 -0
  257. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/remove_member_from_role.yaml +0 -0
  258. {hap_cli-0.8.18 → hap_cli-0.8.20}/sources/v3-api-schema/update_optionset.yaml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hap-cli
3
- Version: 0.8.18
3
+ Version: 0.8.20
4
4
  Summary: CLI harness for Mingdao HAP - Enterprise no-code platform
5
5
  Author: hap-cli
6
6
  License: Apache-2.0
@@ -1,3 +1,3 @@
1
1
  """CLI harness for Mingdao HAP (hap-cli)."""
2
2
 
3
- __version__ = "0.8.18"
3
+ __version__ = "0.8.20"
@@ -1,6 +1,7 @@
1
1
  """Record CRUD module for Mingdao HAP."""
2
2
 
3
3
  import json
4
+ import logging
4
5
  import uuid
5
6
  from typing import Any, Optional
6
7
 
@@ -8,6 +9,8 @@ from hap_cli.core.session import Session, APIError
8
9
  from hap_cli.core import worksheet as ws_mod
9
10
  from hap_cli.core import global_meta as gm
10
11
 
12
+ logger = logging.getLogger("hap_cli.record")
13
+
11
14
  # Discussion source types — pd-openweb src/components/comment/config.js
12
15
  # WORKSHEET = 7 whole-worksheet discussion
13
16
  # WORKSHEETROW = 8 single record/row discussion
@@ -259,6 +262,67 @@ def _legacy_cell_entry(
259
262
  return {"value": _legacy_cell_value(ctrl, value)}
260
263
 
261
264
 
265
+ def _coerce_entity_ids(
266
+ value: Any, id_keys: tuple[str, ...], *, field_label: str,
267
+ ) -> list[str]:
268
+ """Flatten a member / department / org-role / relation cell value to a
269
+ list of id strings, ready to be wrapped as ``[{idKey: id}, ...]``.
270
+
271
+ Accepts every realistic input shape so callers never have to pre-shape:
272
+ * a scalar id, or comma-separated ids (``"a,b"``);
273
+ * a list/tuple mixing ids and ``{idKey: ...}`` objects;
274
+ * a single ``{idKey: ...}`` object — the ``get_records`` read-back
275
+ shape carries display fields (``fullname`` / ``avatar`` / ``name``)
276
+ too, so only the id is kept;
277
+ * a JSON-array/object *string* of the above (e.g. a value copied
278
+ straight out of a record dump).
279
+
280
+ ``id_keys`` lists the accepted id field names in priority order, e.g.
281
+ ``("accountId", "aid")`` for USER_PICKER. An object that carries none of
282
+ them fails loudly instead of being ``str()``-mangled into a junk id —
283
+ that silent mangling sent garbage members to the server before.
284
+ """
285
+ if value is None or value == "":
286
+ return []
287
+ # A JSON-array/object string is parsed structurally rather than
288
+ # comma-split (a member object's JSON has commas inside it).
289
+ if isinstance(value, str):
290
+ s = value.strip()
291
+ if s[:1] in ("[", "{"):
292
+ try:
293
+ value = json.loads(s)
294
+ except (ValueError, TypeError):
295
+ pass
296
+ if isinstance(value, dict):
297
+ items: list[Any] = [value]
298
+ elif isinstance(value, (list, tuple)):
299
+ items = list(value)
300
+ else:
301
+ items = [p for p in str(value).split(",") if p.strip() != ""]
302
+ out: list[str] = []
303
+ for it in items:
304
+ if isinstance(it, dict):
305
+ rid = next(
306
+ (str(it[k]) for k in id_keys if it.get(k) not in (None, "")),
307
+ None,
308
+ )
309
+ if rid is None:
310
+ raise ValueError(
311
+ f"Field '{field_label}': object value is missing an id "
312
+ f"field ({'/'.join(id_keys)}): {it!r}"
313
+ )
314
+ out.append(rid)
315
+ elif isinstance(it, (list, tuple)):
316
+ raise ValueError(
317
+ f"Field '{field_label}': nested array is not a valid id: {it!r}"
318
+ )
319
+ else:
320
+ s2 = str(it).strip()
321
+ if s2:
322
+ out.append(s2)
323
+ return out
324
+
325
+
262
326
  def _legacy_cell_value(ctrl: dict[str, Any], value: Any) -> str:
263
327
  """Serialize one cell to the legacy receiveControls `value`
264
328
  string, per the shapes proven in
@@ -271,14 +335,15 @@ def _legacy_cell_value(ctrl: dict[str, Any], value: Any) -> str:
271
335
  if t == 10: # MULTI_SELECT
272
336
  return json.dumps(_resolve_option_keys(ctrl, value), ensure_ascii=False)
273
337
 
338
+ label = _option_field_label(ctrl)
339
+
274
340
  if t in (29, 35): # RELATE_SHEET / CASCADING_SELECT -> [{"sid": rowId}, ...]
275
341
  # Cascade (35) is a relation to a row in another worksheet, persisted
276
342
  # the same way as a plain relate (29): an array of {"sid": rowId}. A
277
- # bare rowId makes the server answer "服务异常" (bug #642cc8).
278
- if isinstance(value, (list, tuple)):
279
- ids = [str(v) for v in value]
280
- else:
281
- ids = [s for s in str(value).split(",") if s != ""]
343
+ # bare rowId makes the server answer "服务异常" (bug #642cc8). Object
344
+ # inputs ({"sid": rowId, ...} read-back shape) keep only the sid.
345
+ ids = _coerce_entity_ids(value, ("sid", "rowid", "rowId"),
346
+ field_label=label)
282
347
  return json.dumps([{"sid": rid} for rid in ids], ensure_ascii=False)
283
348
 
284
349
  if t == 48: # ORG_ROLE -> [{"organizeId": id}, ...]
@@ -286,20 +351,21 @@ def _legacy_cell_value(ctrl: dict[str, Any], value: Any) -> str:
286
351
  # of {"organizeId": id} objects; organizeName is display-only and the
287
352
  # server resolves it from the id. A bare organizeId is rejected with
288
353
  # "服务异常" (bug #642cc8). See pd-openweb CellControls/OrgRole.jsx.
289
- ids = value if isinstance(value, (list, tuple)) else \
290
- [s for s in str(value).split(",") if s != ""]
291
- return json.dumps([{"organizeId": str(i)} for i in ids],
292
- ensure_ascii=False)
293
-
294
- if t == 26: # USER_PICKER
295
- ids = value if isinstance(value, (list, tuple)) else \
296
- [s for s in str(value).split(",") if s != ""]
297
- return json.dumps([{"accountId": str(i)} for i in ids],
298
- ensure_ascii=False)
299
- if t == 27: # DEPARTMENT
300
- ids = value if isinstance(value, (list, tuple)) else \
301
- [s for s in str(value).split(",") if s != ""]
302
- return json.dumps([{"departmentId": str(i)} for i in ids],
354
+ ids = _coerce_entity_ids(value, ("organizeId", "id"),
355
+ field_label=label)
356
+ return json.dumps([{"organizeId": i} for i in ids], ensure_ascii=False)
357
+
358
+ if t == 26: # USER_PICKER -> always an array of {"accountId": id}, even
359
+ # for a single member. Object inputs (the full member object the UI
360
+ # holds / get_records returns: {accountId, fullname, avatar, ...})
361
+ # are reduced to their accountId a bare object would fail the save.
362
+ ids = _coerce_entity_ids(value, ("accountId", "aid"),
363
+ field_label=label)
364
+ return json.dumps([{"accountId": i} for i in ids], ensure_ascii=False)
365
+ if t == 27: # DEPARTMENT -> [{"departmentId": id}, ...]
366
+ ids = _coerce_entity_ids(value, ("departmentId", "id"),
367
+ field_label=label)
368
+ return json.dumps([{"departmentId": i} for i in ids],
303
369
  ensure_ascii=False)
304
370
 
305
371
  if t == 36: # SWITCH / checkbox
@@ -320,6 +386,41 @@ def _legacy_cell_value(ctrl: dict[str, Any], value: Any) -> str:
320
386
  return "" if value is None else str(value)
321
387
 
322
388
 
389
+ def _controls_for_write(
390
+ session: Session, worksheet_id: str,
391
+ ) -> list[dict[str, Any]]:
392
+ """Fetch the worksheet's controls for a write, refusing an empty list.
393
+
394
+ Every write path type-encodes its cells against this metadata, so an
395
+ empty list silently disables the encoder: option/member/relation/
396
+ sub-table values would all take the "unknown field -> send the raw
397
+ value" fallback and reach the server in whatever shape the caller
398
+ happened to type. That is how a hand-written 1-D sub-table value
399
+ (``[{childControlId: value}, ...]``) escaped to the server and failed
400
+ deserialization into ``List<Cell>`` — the encoder would otherwise have
401
+ rewritten it to the 2-D form.
402
+
403
+ ``get_worksheet_controls`` answers ``[]`` for any response that does
404
+ not carry a ``controls`` list — a soft failure, ``data: null``, or a
405
+ permission-degraded envelope — none of which raise. A real worksheet
406
+ always has controls, so an empty list means "we could not read the
407
+ fields", never "this worksheet has none". Abort before writing.
408
+ """
409
+ controls = ws_mod.get_worksheet_controls(session, worksheet_id)
410
+ if not controls:
411
+ logger.warning(
412
+ "GetWorksheetControls returned no fields for worksheet %s; "
413
+ "aborting the write instead of sending unencoded cells",
414
+ worksheet_id,
415
+ )
416
+ raise ValueError(
417
+ f"Could not read the fields of worksheet {worksheet_id}, so "
418
+ "nothing was written. Check the worksheet id and that your "
419
+ "account can open it, then try again."
420
+ )
421
+ return controls
422
+
423
+
323
424
  def encode_controls_by_id(
324
425
  session: Session,
325
426
  worksheet_id: str,
@@ -356,7 +457,7 @@ def encode_controls_by_id(
356
457
  (in-place edits keyed by ``rowid``, fresh adds, and deletes — see
357
458
  :func:`_sublist_update_value`).
358
459
  """
359
- controls = ws_mod.get_worksheet_controls(session, worksheet_id)
460
+ controls = _controls_for_write(session, worksheet_id)
360
461
  by_id: dict[str, dict[str, Any]] = {
361
462
  c.get("controlId"): c for c in controls if c.get("controlId")
362
463
  }
@@ -371,6 +472,14 @@ def encode_controls_by_id(
371
472
  raw = rc.get("value")
372
473
  ctrl = by_id.get(cid) or by_ref.get(cid)
373
474
  if ctrl is None:
475
+ # Deliberate escape hatch (see docstring), but a value that
476
+ # skips the type encoder is exactly how a malformed cell
477
+ # reaches the server, so leave a trace: this is the only
478
+ # place a write can go out unencoded.
479
+ logger.warning(
480
+ "Field %r is not on worksheet %s; its value is sent "
481
+ "unencoded (no type conversion applied)", cid, worksheet_id,
482
+ )
374
483
  out.append({"controlId": cid, "value": raw})
375
484
  continue
376
485
  out.append({
@@ -540,7 +649,7 @@ def batch_create_by_name(
540
649
  by child controlId; single/multi select -> option keys; etc.).
541
650
  Evidence: hap_cli/tests/answer_create_record.md.
542
651
  """
543
- controls = ws_mod.get_worksheet_controls(session, worksheet_id)
652
+ controls = _controls_for_write(session, worksheet_id)
544
653
  by_name: dict[str, dict[str, Any]] = {}
545
654
  for c in controls:
546
655
  for key in (c.get("controlName"), c.get("alias"),
@@ -599,7 +708,7 @@ def batch_update_by_name(
599
708
  :func:`_sublist_update_value`). Record updates never use the V3
600
709
  rows endpoint — V3 silently drops relation / sub-table cells.
601
710
  """
602
- controls = ws_mod.get_worksheet_controls(session, worksheet_id)
711
+ controls = _controls_for_write(session, worksheet_id)
603
712
  by_name: dict[str, dict[str, Any]] = {}
604
713
  for c in controls:
605
714
  for key in (c.get("controlName"), c.get("alias"),
@@ -529,28 +529,39 @@ class Session:
529
529
  # Detect each and unwrap accordingly.
530
530
  if not isinstance(result, dict):
531
531
  return result
532
+ # A non-empty ``exception`` means the call failed, whatever envelope
533
+ # it arrived in. This is the frontend's own and only test — mdyAPI
534
+ # rejects on ``if (responseData.exception)`` alone and never looks at
535
+ # ``status`` (pd-openweb src/common/global.js:846-849). Checking it
536
+ # before any unwrapping is what makes workflow failures visible: the
537
+ # workflow envelope has no ``state``, so it used to skip the error
538
+ # branch entirely and hand the caller a "successful" empty result.
539
+ exception_msg = str(result.get("exception", "") or "")
540
+ if exception_msg:
541
+ code = result.get("code", result.get("state"))
542
+ # Some HAP deployments tunnel auth failures through the exception
543
+ # text/code instead of HTTP 401. Surface those as
544
+ # AuthenticationError so the CLI shows the login hint.
545
+ if _looks_like_auth_failure(code, exception_msg):
546
+ raise AuthenticationError(exception_msg, status_code=None)
547
+ raise APIError(exception_msg, code=code)
532
548
  if "status" in result and "data" in result and "state" not in result:
533
- # Workflow envelope. status==0 is "ok" here historically; we
534
- # don't have a clean way to detect failures via status alone,
535
- # so we just unwrap data and trust HTTP status for errors.
549
+ # Workflow envelope: ``status`` carries no success/failure
550
+ # semantics (the frontend ignores it), and a failure would have
551
+ # been caught by the exception check above.
536
552
  return result.get("data", result)
537
553
  if "state" not in result:
538
554
  return result
539
555
  if result.get("state") == 0:
540
- exception_msg = str(result.get("exception", "") or "")
541
556
  code = result.get("code")
542
- # Some HAP deployments tunnel auth failures through state=0 with
543
- # specific codes/messages instead of HTTP 401. Surface those as
544
- # AuthenticationError too so the CLI shows the login hint.
545
- if _looks_like_auth_failure(code, exception_msg):
557
+ # state=0 with no exception text still a failure, just an
558
+ # unlabelled one.
559
+ if _looks_like_auth_failure(code, ""):
546
560
  raise AuthenticationError(
547
- exception_msg or "Authentication failed (upstream reported login required).",
561
+ "Authentication failed (upstream reported login required).",
548
562
  status_code=None,
549
563
  )
550
- raise APIError(
551
- exception_msg or "Unknown API error",
552
- code=code,
553
- )
564
+ raise APIError("Unknown API error", code=code)
554
565
  return result.get("data", result)
555
566
 
556
567
  # ── API Call Methods ──────────────────────────────────────────────────
@@ -68,10 +68,25 @@ def get_worksheet_controls(
68
68
  if isinstance(result, dict):
69
69
  inner = result.get("data")
70
70
  if isinstance(inner, dict):
71
- return inner.get("controls", [])
72
- if isinstance(inner, list):
73
- return inner
74
- return result.get("controls", [])
71
+ controls = inner.get("controls", [])
72
+ elif isinstance(inner, list):
73
+ controls = inner
74
+ else:
75
+ controls = result.get("controls", [])
76
+ else:
77
+ controls = []
78
+ if not controls:
79
+ # A real worksheet always has controls, so an empty result means
80
+ # the envelope was not the expected shape (soft failure, data:
81
+ # null, permission-degraded response). Callers that type-encode
82
+ # cells against this metadata silently lose their encoding when
83
+ # it comes back empty, so leave a trace at the source.
84
+ logger.warning(
85
+ "GetWorksheetControls returned no fields for worksheet %s "
86
+ "(response keys: %s)", worksheet_id,
87
+ sorted(result) if isinstance(result, dict) else type(result).__name__,
88
+ )
89
+ return controls
75
90
 
76
91
 
77
92
  def get_worksheet_views(
@@ -0,0 +1,179 @@
1
+ """A record write must abort when the worksheet's fields can't be read.
2
+
3
+ Traced from a live backend error on ``Worksheet.AddWSRowData.DetailTable
4
+ .Details``:
5
+
6
+ Cannot deserialize the current JSON object (e.g. {"name":"value"})
7
+ into type 'System.Collections.Generic.List`1[MD.Entity.Worksheet.Cell]'
8
+ ... Path '[0].66961f256a6cbc06cce98326'
9
+
10
+ The server had received a sub-table cell whose value was a *1-D* array of
11
+ ``{childControlId: value}`` maps instead of the 2-D
12
+ ``[[{controlId, value}, ...], ...]`` array it deserializes into
13
+ ``List<Cell>``.
14
+
15
+ ``encode_controls_by_id`` normally rewrites exactly that mistake: it looks
16
+ the sub-table control up, runs ``_legacy_cell_value`` (type 34) over the
17
+ value and re-emits the 2-D form with a ``tempRowId`` cell per row. The one
18
+ way the wrong shape can escape is the "control not found -> send the raw
19
+ value" fallback. And because ``get_worksheet_controls`` returns ``[]``
20
+ whenever the response doesn't carry a ``controls`` list (soft failure,
21
+ ``data: null``, permission-degraded envelope), *every* cell of that write
22
+ silently took the fallback — the type encoder was effectively switched
23
+ off with no signal to the user.
24
+
25
+ Two guards, both exercised here:
26
+ * an empty control list on a write path fails loudly, and
27
+ * an individual unresolved field reference is logged before its value
28
+ is passed through.
29
+ """
30
+
31
+ import json
32
+ import logging
33
+
34
+ import pytest
35
+
36
+ from hap_cli.core import record as rec_mod
37
+
38
+
39
+ _SUB_CTRL = {
40
+ "controlId": "66962b546c73406b9268582d",
41
+ "controlName": "明细",
42
+ "type": 34,
43
+ "relationControls": [
44
+ {"controlId": "66961f256a6cbc06cce98326", "controlName": "编号",
45
+ "type": 2},
46
+ {"controlId": "66962af95957d709b346d1ad", "controlName": "数量",
47
+ "type": 6},
48
+ ],
49
+ }
50
+
51
+ # The shape an LLM / hand-written payload typically produces: one map per
52
+ # child row, keyed by child controlId, values left in their native JSON
53
+ # types. This is what the server choked on.
54
+ _MAP_SHAPED_ROWS = json.dumps([
55
+ {"66961f256a6cbc06cce98326": "WL001238",
56
+ "66962af95957d709b346d1ad": 2},
57
+ ])
58
+
59
+
60
+ @pytest.fixture
61
+ def controls(monkeypatch):
62
+ """Stub the worksheet control fetch; assign to the list to change it."""
63
+ holder = [_SUB_CTRL]
64
+
65
+ def _fake(session, worksheet_id, app_id=""):
66
+ return holder[0] if isinstance(holder[0], list) else holder
67
+
68
+ monkeypatch.setattr(rec_mod.ws_mod, "get_worksheet_controls", _fake)
69
+ return holder
70
+
71
+
72
+ def _set(holder, value):
73
+ holder[0] = value
74
+
75
+
76
+ @pytest.fixture
77
+ def warnings():
78
+ """Collect this module's warnings off its own logger.
79
+
80
+ ``caplog`` listens on the root logger, and ``hap config log on``
81
+ detaches ``hap_cli`` from root (``propagate = False``), so a test that
82
+ happens to run after one is deaf to these records. Attaching here is
83
+ independent of that.
84
+ """
85
+ records: list[logging.LogRecord] = []
86
+
87
+ class _Collect(logging.Handler):
88
+ def emit(self, record):
89
+ records.append(record)
90
+
91
+ handler = _Collect(level=logging.WARNING)
92
+ log = logging.getLogger("hap_cli.record")
93
+ log.addHandler(handler)
94
+ try:
95
+ yield records
96
+ finally:
97
+ log.removeHandler(handler)
98
+
99
+
100
+ class TestEmptyControlListAborts:
101
+ def test_encode_controls_by_id_raises(self, controls):
102
+ _set(controls, [])
103
+ with pytest.raises(ValueError, match="ws_1"):
104
+ rec_mod.encode_controls_by_id(
105
+ None, "ws_1",
106
+ [{"controlId": _SUB_CTRL["controlId"],
107
+ "value": _MAP_SHAPED_ROWS}],
108
+ )
109
+
110
+ def test_update_path_raises_too(self, controls):
111
+ _set(controls, [])
112
+ with pytest.raises(ValueError):
113
+ rec_mod.encode_controls_by_id(
114
+ None, "ws_1",
115
+ [{"controlId": _SUB_CTRL["controlId"], "value": "x"}],
116
+ for_update=True,
117
+ )
118
+
119
+ def test_batch_create_by_name_raises(self, controls):
120
+ _set(controls, [])
121
+ with pytest.raises(ValueError, match="ws_1"):
122
+ rec_mod.batch_create_by_name(None, "ws_1", [{"明细": []}])
123
+
124
+ def test_batch_update_by_name_raises(self, controls):
125
+ _set(controls, [])
126
+ with pytest.raises(ValueError, match="ws_1"):
127
+ rec_mod.batch_update_by_name(
128
+ None, "ws_1", [{"rowid": "r1", "明细": []}])
129
+
130
+ def test_error_names_the_worksheet_not_the_field(self, controls):
131
+ """The old message blamed the field ("Field '明细' not found").
132
+
133
+ With no controls at all the field is not the problem — say so, or
134
+ the user goes hunting for a field that exists.
135
+ """
136
+ _set(controls, [])
137
+ with pytest.raises(ValueError) as exc:
138
+ rec_mod.batch_create_by_name(None, "ws_1", [{"明细": []}])
139
+ assert "not found" not in str(exc.value)
140
+
141
+
142
+ class TestUnresolvedFieldIsLogged:
143
+ def test_warning_names_the_field(self, controls, warnings):
144
+ out = rec_mod.encode_controls_by_id(
145
+ None, "ws_1", [{"controlId": "c_nope", "value": "v"}])
146
+ # Still passed through — explicit ids must keep reaching the server.
147
+ assert out == [{"controlId": "c_nope", "value": "v"}]
148
+ assert any("c_nope" in r.getMessage() for r in warnings)
149
+
150
+ def test_no_warning_when_resolved(self, controls, warnings):
151
+ rec_mod.encode_controls_by_id(
152
+ None, "ws_1",
153
+ [{"controlId": _SUB_CTRL["controlId"], "value": "[]"}])
154
+ assert warnings == []
155
+
156
+ def test_empty_control_list_is_logged_before_the_abort(
157
+ self, controls, warnings):
158
+ _set(controls, [])
159
+ with pytest.raises(ValueError):
160
+ rec_mod.encode_controls_by_id(
161
+ None, "ws_1", [{"controlId": "c_any", "value": "v"}])
162
+ assert any("ws_1" in r.getMessage() for r in warnings)
163
+
164
+
165
+ class TestMapShapedSubTableIsStillNormalized:
166
+ """The guards must not weaken the repair that already works."""
167
+
168
+ def test_map_rows_become_2d_cell_lists(self, controls):
169
+ out = rec_mod.encode_controls_by_id(
170
+ None, "ws_1",
171
+ [{"controlId": _SUB_CTRL["controlId"],
172
+ "value": _MAP_SHAPED_ROWS}],
173
+ )
174
+ rows = json.loads(out[0]["value"])
175
+ assert isinstance(rows, list) and isinstance(rows[0], list)
176
+ cells = {c["controlId"]: c["value"] for c in rows[0]}
177
+ assert cells["66961f256a6cbc06cce98326"] == "WL001238"
178
+ assert cells["66962af95957d709b346d1ad"] == "2" # stringified
179
+ assert "tempRowId" in cells
@@ -0,0 +1,103 @@
1
+ """Member / department / org-role / relation cells must always serialize
2
+ to a JSON *array* of id objects, and must accept object-shaped inputs.
3
+
4
+ A USER_PICKER (26) cell is always an array, even for a single member
5
+ (evidence: a bare ``{"accountId":...}`` object on AddWorksheetRow fails
6
+ server-side). hap-cli's encoder already wraps scalars in an array, but it
7
+ mangled object-shaped inputs: ``[{"accountId": str(i)} for i in value]``
8
+ ran ``str()`` over a whole member dict when the value was a member object
9
+ or — the natural ``get_records`` read-back shape — a list of member
10
+ objects ``[{"accountId","fullname","avatar",...}]``. The id is now
11
+ extracted from such objects and the cell is always emitted as an array.
12
+
13
+ The same fix covers DEPARTMENT (27, ``departmentId``), ORG_ROLE
14
+ (48, ``organizeId``) and RELATE_SHEET/CASCADING (29/35, ``sid``).
15
+ """
16
+
17
+ import json
18
+
19
+ import pytest
20
+
21
+ from hap_cli.core import record as rec_mod
22
+
23
+
24
+ def _val(type_code, value):
25
+ return rec_mod._legacy_cell_value({"type": type_code, "controlName": "f"}, value)
26
+
27
+
28
+ class TestUserPickerAlwaysArray:
29
+ def test_single_account_id_string(self):
30
+ assert _val(26, "a1") == '[{"accountId": "a1"}]'
31
+
32
+ def test_comma_separated_ids(self):
33
+ assert _val(26, "a1,a2") == '[{"accountId": "a1"}, {"accountId": "a2"}]'
34
+
35
+ def test_list_of_ids(self):
36
+ assert _val(26, ["a1", "a2"]) == \
37
+ '[{"accountId": "a1"}, {"accountId": "a2"}]'
38
+
39
+ def test_single_member_object_keeps_only_account_id(self):
40
+ v = {"accountId": "a1", "fullname": "林梦阳", "name": "林梦阳",
41
+ "avatar": "https://x", "avatarMiddle": "https://y"}
42
+ assert _val(26, v) == '[{"accountId": "a1"}]'
43
+
44
+ def test_list_of_member_objects_readback_shape(self):
45
+ v = [{"accountId": "a1", "fullname": "甲", "avatar": "u1"},
46
+ {"accountId": "a2", "fullname": "乙", "avatar": "u2"}]
47
+ assert _val(26, v) == \
48
+ '[{"accountId": "a1"}, {"accountId": "a2"}]'
49
+
50
+ def test_json_array_of_objects_string(self):
51
+ v = json.dumps([{"accountId": "a1", "fullname": "甲"}])
52
+ assert _val(26, v) == '[{"accountId": "a1"}]'
53
+
54
+ def test_json_object_string(self):
55
+ v = json.dumps({"accountId": "a1", "fullname": "甲"})
56
+ assert _val(26, v) == '[{"accountId": "a1"}]'
57
+
58
+ def test_member_object_without_account_id_rejected(self):
59
+ with pytest.raises(ValueError):
60
+ _val(26, {"fullname": "甲", "avatar": "u"})
61
+
62
+ def test_empty_is_empty_array(self):
63
+ assert _val(26, "") == "[]"
64
+ assert _val(26, None) == "[]"
65
+
66
+
67
+ class TestDepartmentObjectShape:
68
+ def test_object_keeps_department_id(self):
69
+ v = {"departmentId": "d1", "departmentName": "研发"}
70
+ assert _val(27, v) == '[{"departmentId": "d1"}]'
71
+
72
+ def test_list_of_objects(self):
73
+ v = [{"departmentId": "d1"}, {"departmentId": "d2"}]
74
+ assert _val(27, v) == \
75
+ '[{"departmentId": "d1"}, {"departmentId": "d2"}]'
76
+
77
+ def test_plain_id_still_works(self):
78
+ assert _val(27, "d1") == '[{"departmentId": "d1"}]'
79
+
80
+
81
+ class TestOrgRoleObjectShape:
82
+ def test_object_keeps_organize_id(self):
83
+ v = {"organizeId": "o1", "organizeName": "主管"}
84
+ assert _val(48, v) == '[{"organizeId": "o1"}]'
85
+
86
+ def test_plain_id_still_works(self):
87
+ assert _val(48, "o1") == '[{"organizeId": "o1"}]'
88
+
89
+
90
+ class TestRelateObjectShape:
91
+ def test_relate_object_keeps_sid(self):
92
+ v = {"sid": "row1", "name": "记录甲"}
93
+ assert _val(29, v) == '[{"sid": "row1"}]'
94
+
95
+ def test_relate_list_of_objects_readback(self):
96
+ v = [{"sid": "row1", "name": "甲"}, {"sid": "row2", "name": "乙"}]
97
+ assert _val(29, v) == '[{"sid": "row1"}, {"sid": "row2"}]'
98
+
99
+ def test_relate_plain_rowid_still_works(self):
100
+ assert _val(29, "row1") == '[{"sid": "row1"}]'
101
+
102
+ def test_cascading_object_keeps_sid(self):
103
+ assert _val(35, {"sid": "row1"}) == '[{"sid": "row1"}]'