labtasker 0.2.10__tar.gz → 0.2.12__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 (216) hide show
  1. {labtasker-0.2.10 → labtasker-0.2.12}/PKG-INFO +2 -1
  2. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/__init__.py +1 -1
  3. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/worker.py +7 -0
  4. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/client_api.py +2 -2
  5. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/api.py +30 -5
  6. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cli_utils.py +2 -2
  7. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/events.py +2 -2
  8. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/heartbeat.py +16 -5
  9. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/job_runner.py +8 -2
  10. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/query_transpiler.py +23 -4
  11. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/utils.py +27 -1
  12. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/constants.py +0 -1
  13. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/database.py +41 -11
  14. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/endpoints.py +5 -7
  15. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/fsm.py +40 -3
  16. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker.egg-info/SOURCES.txt +2 -1
  17. {labtasker-0.2.10 → labtasker-0.2.12}/pyproject.toml +2 -2
  18. labtasker-0.2.12/tests/test_client/test_core/test_heartbeat_e2e.py +73 -0
  19. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_job_runner.py +36 -0
  20. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_fsm.py +1 -1
  21. {labtasker-0.2.10 → labtasker-0.2.12}/LICENSE +0 -0
  22. {labtasker-0.2.10 → labtasker-0.2.12}/MANIFEST.in +0 -0
  23. {labtasker-0.2.10 → labtasker-0.2.12}/README.md +0 -0
  24. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/__main__.py +0 -0
  25. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/api_models.py +0 -0
  26. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/__init__.py +0 -0
  27. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/__init__.py +0 -0
  28. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/cli.py +0 -0
  29. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/config.py +0 -0
  30. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/event.py +0 -0
  31. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/init.py +0 -0
  32. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/loop.py +0 -0
  33. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/queue.py +0 -0
  34. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/cli/task.py +0 -0
  35. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/__init__.py +0 -0
  36. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/LabCmd.g4 +0 -0
  37. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/LabCmdLexer.g4 +0 -0
  38. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/__init__.py +0 -0
  39. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/generated/LabCmd.py +0 -0
  40. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/generated/LabCmdLexer.py +0 -0
  41. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/generated/LabCmdListener.py +0 -0
  42. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/generated/__init__.py +0 -0
  43. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/cmd_parser/parser.py +0 -0
  44. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/config.py +0 -0
  45. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/context.py +0 -0
  46. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/exceptions.py +0 -0
  47. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/logging.py +0 -0
  48. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/pager.py +0 -0
  49. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/paths.py +0 -0
  50. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/plugin_utils.py +0 -0
  51. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/resolver/__init__.py +0 -0
  52. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/resolver/models.py +0 -0
  53. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/resolver/utils.py +0 -0
  54. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/core/version_checker.py +0 -0
  55. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/templates/labtasker_root/.gitignore +0 -0
  56. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/templates/labtasker_root/client.toml +0 -0
  57. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/client/templates/labtasker_root/logs/.gitkeep +0 -0
  58. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/filtering.py +0 -0
  59. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/security.py +0 -0
  60. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/__init__.py +0 -0
  61. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/cli.py +0 -0
  62. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/config.py +0 -0
  63. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/db_utils.py +0 -0
  64. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/dependencies.py +0 -0
  65. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/embedded_db.py +0 -0
  66. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/event_manager.py +0 -0
  67. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/server/logging.py +0 -0
  68. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/utils.py +0 -0
  69. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/README.txt +0 -0
  70. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/__init__.py +0 -0
  71. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/BufferedTokenStream.py +0 -0
  72. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/CommonTokenFactory.py +0 -0
  73. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/CommonTokenStream.py +0 -0
  74. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/FileStream.py +0 -0
  75. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/InputStream.py +0 -0
  76. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/IntervalSet.py +0 -0
  77. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/LL1Analyzer.py +0 -0
  78. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/Lexer.py +0 -0
  79. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/ListTokenSource.py +0 -0
  80. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/Parser.py +0 -0
  81. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/ParserInterpreter.py +0 -0
  82. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/ParserRuleContext.py +0 -0
  83. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/PredictionContext.py +0 -0
  84. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/Recognizer.py +0 -0
  85. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/RuleContext.py +0 -0
  86. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/StdinStream.py +0 -0
  87. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/Token.py +0 -0
  88. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/TokenStreamRewriter.py +0 -0
  89. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/Utils.py +0 -0
  90. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/__init__.py +0 -0
  91. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/_pygrun.py +0 -0
  92. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATN.py +0 -0
  93. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNConfig.py +0 -0
  94. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNConfigSet.py +0 -0
  95. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNDeserializationOptions.py +0 -0
  96. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNDeserializer.py +0 -0
  97. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNSimulator.py +0 -0
  98. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNState.py +0 -0
  99. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ATNType.py +0 -0
  100. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/LexerATNSimulator.py +0 -0
  101. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/LexerAction.py +0 -0
  102. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/LexerActionExecutor.py +0 -0
  103. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/ParserATNSimulator.py +0 -0
  104. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/PredictionMode.py +0 -0
  105. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/SemanticContext.py +0 -0
  106. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/Transition.py +0 -0
  107. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/atn/__init__.py +0 -0
  108. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/dfa/DFA.py +0 -0
  109. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/dfa/DFASerializer.py +0 -0
  110. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/dfa/DFAState.py +0 -0
  111. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/dfa/__init__.py +0 -0
  112. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/error/DiagnosticErrorListener.py +0 -0
  113. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/error/ErrorListener.py +0 -0
  114. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/error/ErrorStrategy.py +0 -0
  115. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/error/Errors.py +0 -0
  116. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/error/__init__.py +0 -0
  117. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/Chunk.py +0 -0
  118. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/ParseTreeMatch.py +0 -0
  119. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/ParseTreePattern.py +0 -0
  120. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/ParseTreePatternMatcher.py +0 -0
  121. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/RuleTagToken.py +0 -0
  122. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/TokenTagToken.py +0 -0
  123. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/Tree.py +0 -0
  124. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/Trees.py +0 -0
  125. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/tree/__init__.py +0 -0
  126. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/xpath/XPath.py +0 -0
  127. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/xpath/XPathLexer.py +0 -0
  128. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/antlr4/xpath/__init__.py +0 -0
  129. {labtasker-0.2.10 → labtasker-0.2.12}/labtasker/vendor/vendor.txt +0 -0
  130. {labtasker-0.2.10 → labtasker-0.2.12}/setup.cfg +0 -0
  131. {labtasker-0.2.10 → labtasker-0.2.12}/tests/__init__.py +0 -0
  132. {labtasker-0.2.10 → labtasker-0.2.12}/tests/conftest.py +0 -0
  133. {labtasker-0.2.10 → labtasker-0.2.12}/tests/demo_pager_iterator.py +0 -0
  134. {labtasker-0.2.10 → labtasker-0.2.12}/tests/dummy_jobs/job_1.py +0 -0
  135. {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/__init__.py +0 -0
  136. {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/database/__init__.py +0 -0
  137. {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/database/mock.py +0 -0
  138. {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/database/real.py +0 -0
  139. {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/logging.py +0 -0
  140. {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/mock_datetime_now.py +0 -0
  141. {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/server/__init__.py +0 -0
  142. {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/server/async_app.py +0 -0
  143. {labtasker-0.2.10 → labtasker-0.2.12}/tests/fixtures/server/sync_app.py +0 -0
  144. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_api_models.py +0 -0
  145. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/__init__.py +0 -0
  146. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/conftest.py +0 -0
  147. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/__init__.py +0 -0
  148. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/conftest.py +0 -0
  149. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_basic.py +0 -0
  150. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_config.py +0 -0
  151. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_event.py +0 -0
  152. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_init.py +0 -0
  153. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_loop.py +0 -0
  154. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_queue.py +0 -0
  155. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_task.py +0 -0
  156. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_cli/test_worker.py +0 -0
  157. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/__init__.py +0 -0
  158. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_cli_utils.py +0 -0
  159. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_event/__init__.py +0 -0
  160. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_event/test_concurrency_job_flow_event.py +0 -0
  161. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_event/test_event_listener_basic.py +0 -0
  162. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_event/test_event_listener_entity_data.py +0 -0
  163. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_event/test_various_actions.py +0 -0
  164. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_event/utils.py +0 -0
  165. /labtasker-0.2.10/tests/test_client/test_core/test_heartbeat.py → /labtasker-0.2.12/tests/test_client/test_core/test_heartbeat_unit.py +0 -0
  166. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_logging.py +0 -0
  167. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_loop_internal_error_handler.py +0 -0
  168. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_pager_iterator.py +0 -0
  169. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_parser.py +0 -0
  170. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_query_transpiler/__init__.py +0 -0
  171. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_query_transpiler/conftest.py +0 -0
  172. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_query_transpiler/test_behavior.py +0 -0
  173. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_query_transpiler/test_matching.py +0 -0
  174. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_query_transpiler/test_utils.py +0 -0
  175. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_query_transpiler/utils.py +0 -0
  176. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_resolver.py +0 -0
  177. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_concurrency/__init__.py +0 -0
  178. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_concurrency/run_concurrent.py +0 -0
  179. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_concurrency/test_runner_concurrency_success_failure.py +0 -0
  180. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_concurrency/test_runner_high_concurrency.py +0 -0
  181. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_timeout/__init__.py +0 -0
  182. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_timeout/conftest.py +0 -0
  183. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_timeout/test_job_runner_timeout.py +0 -0
  184. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_timeout/test_job_runner_with_resolver_timeout.py +0 -0
  185. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_runner_with_resolver.py +0 -0
  186. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_server_notification_and_client_version.py +0 -0
  187. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_client/test_core/test_version_checker.py +0 -0
  188. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_filtering/__init__.py +0 -0
  189. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_filtering/exception_utils.py +0 -0
  190. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_filtering/test_exception_filtering.py +0 -0
  191. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_mock_time.py +0 -0
  192. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_security.py +0 -0
  193. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/__init__.py +0 -0
  194. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/conftest.py +0 -0
  195. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_database/__init__.py +0 -0
  196. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_database/conftest.py +0 -0
  197. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_database/test_database_basic.py +0 -0
  198. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_database/test_fetch_extra_filter.py +0 -0
  199. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_database/test_query_dict_to_mongo_filter.py +0 -0
  200. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_database/test_required_field_fetching.py +0 -0
  201. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_db_utils/__init__.py +0 -0
  202. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_db_utils/test_arg_match.py +0 -0
  203. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_db_utils/test_keys_to_query_dict.py +0 -0
  204. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_db_utils/test_keys_to_query_dict_deepest.py +0 -0
  205. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_db_utils/test_keys_to_query_dict_topmost.py +0 -0
  206. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_embedded_db.py +0 -0
  207. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_endpoint/__init__.py +0 -0
  208. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_endpoint/test_event_basic.py +0 -0
  209. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_endpoint/test_server.py +0 -0
  210. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_endpoint/test_server_async.py +0 -0
  211. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_endpoint/test_server_async_ping.py +0 -0
  212. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_server/test_get_verified_queue_dependency.py +0 -0
  213. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_utils/__init__.py +0 -0
  214. {labtasker-0.2.10 → labtasker-0.2.12}/tests/test_utils/test_utils.py +0 -0
  215. {labtasker-0.2.10 → labtasker-0.2.12}/tests/utils.py +0 -0
  216. {labtasker-0.2.10 → labtasker-0.2.12}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labtasker
3
- Version: 0.2.10
3
+ Version: 0.2.12
4
4
  Summary: A task queue system for lab experiments
5
5
  Author-email: Your Name <your.email@example.com>
6
6
  License: Apache License 2.0
@@ -57,6 +57,7 @@ Requires-Dist: asgi-lifespan<3.0.0,>=2.1.0; extra == "dev"
57
57
  Requires-Dist: tox<4.29.0,>=4.24.0; extra == "dev"
58
58
  Requires-Dist: pytest-dependency<0.7.0,>=0.6.0; extra == "dev"
59
59
  Requires-Dist: pytest-sugar<2.0.0,>=1.0.0; extra == "dev"
60
+ Requires-Dist: rust-just<2.0.0,>=1.42.4; extra == "dev"
60
61
  Provides-Extra: doc
61
62
  Requires-Dist: mkdocs-material<9.7.0,>=9.6.5; extra == "doc"
62
63
  Requires-Dist: mkdocs-glightbox<0.5.0,>=0.4.0; extra == "doc"
@@ -1,4 +1,4 @@
1
- __version__ = "0.2.10"
1
+ __version__ = "0.2.12"
2
2
 
3
3
  from labtasker.client.client_api import *
4
4
  from labtasker.client.core.config import get_client_config
@@ -102,6 +102,12 @@ def ls(
102
102
  "--name",
103
103
  help="Filter by worker name.",
104
104
  ),
105
+ status: Optional[str] = typer.Option(
106
+ None,
107
+ "--status",
108
+ "-s",
109
+ help="Filter by worker status. One of `active`, `suspended`, `crashed`.",
110
+ ),
105
111
  extra_filter: Optional[str] = typer.Option(
106
112
  None,
107
113
  "--extra-filter",
@@ -164,6 +170,7 @@ def ls(
164
170
  ls_workers,
165
171
  worker_id=worker_id,
166
172
  worker_name=worker_name,
173
+ status=status,
167
174
  extra_filter=extra_filter,
168
175
  ),
169
176
  offset=offset,
@@ -70,7 +70,7 @@ assert len(set(__all__)) == len(__all__), "Duplicated symbols in __all__"
70
70
 
71
71
  def loop(
72
72
  required_fields: Optional[List[str]] = None,
73
- extra_filter: Optional[Dict[str, Any]] = None,
73
+ extra_filter: Optional[Union[str, Dict[str, Any]]] = None,
74
74
  cmd: Optional[Union[str, List[str]]] = None,
75
75
  worker_id: Optional[str] = None,
76
76
  create_worker_kwargs: Optional[Dict[str, Any]] = None,
@@ -82,7 +82,7 @@ def loop(
82
82
 
83
83
  Args:
84
84
  required_fields: Fields (or extra fields other than specified using Required(...)) required for task execution in a dot-separated manner. E.g. ["arg1.arg11", "arg2.arg22"]
85
- extra_filter: Additional filtering criteria for tasks
85
+ extra_filter: Additional filtering criteria for tasks. Dict in MongoDB syntax or string in Python syntax is allowed.
86
86
  cmd: Command line arguments that runs current process. Default to sys.argv
87
87
  worker_id: Specific worker ID to use
88
88
  create_worker_kwargs: Arguments for default worker creation
@@ -35,6 +35,7 @@ from labtasker.client.core.utils import (
35
35
  cast_http_error,
36
36
  display_server_notifications,
37
37
  raise_for_status,
38
+ transpile_query_safe,
38
39
  )
39
40
  from labtasker.constants import Priority
40
41
  from labtasker.security import SecretStr, get_auth_headers
@@ -63,11 +64,21 @@ __all__ = [
63
64
  ]
64
65
 
65
66
 
67
+ def _is_network_transient_error(exception):
68
+ return isinstance(exception, (httpx.TransportError, ConnectionError, TimeoutError))
69
+
70
+
66
71
  def _network_err_retry(func):
67
72
  @wraps(func)
68
73
  def wrapper(*args, **kwargs):
69
74
  return stamina.retry(
70
- on=httpx.TransportError, attempts=5, wait_initial=0.5, wait_max=10.0
75
+ on=_is_network_transient_error,
76
+ attempts=10,
77
+ timeout=100.0,
78
+ wait_initial=0.5,
79
+ wait_max=16.0,
80
+ wait_jitter=1.0,
81
+ wait_exp_base=2.0,
71
82
  )(func)(*args, **kwargs)
72
83
 
73
84
  return wrapper
@@ -193,7 +204,7 @@ def fetch_task(
193
204
  heartbeat_timeout: Optional[float] = None,
194
205
  start_heartbeat: bool = True,
195
206
  required_fields: Optional[List[str]] = None,
196
- extra_filter: Optional[Dict[str, Any]] = None,
207
+ extra_filter: Optional[Union[str, Dict[str, Any]]] = None,
197
208
  client: Optional[httpx.Client] = None,
198
209
  cmd: Optional[Union[str, List[str]]] = None,
199
210
  ) -> TaskFetchResponse:
@@ -206,6 +217,9 @@ def fetch_task(
206
217
  "Either eta_max or start_heartbeat must be specified."
207
218
  )
208
219
 
220
+ if isinstance(extra_filter, str): # transpile to mongodb query
221
+ extra_filter = transpile_query_safe(query_str=extra_filter)
222
+
209
223
  payload = TaskFetchRequest(
210
224
  worker_id=worker_id,
211
225
  eta_max=eta_max,
@@ -268,12 +282,15 @@ def report_task_status(
268
282
  @_network_err_retry
269
283
  def refresh_task_heartbeat(
270
284
  task_id: str,
285
+ worker_id: Optional[str] = None,
271
286
  client: Optional[httpx.Client] = None,
272
287
  ) -> None:
273
288
  """Refresh the heartbeat of a task."""
274
289
  if client is None:
275
290
  client = get_httpx_client()
276
- response = client.post(f"/api/v1/queues/me/tasks/{task_id}/heartbeat")
291
+ response = client.post(
292
+ f"/api/v1/queues/me/tasks/{task_id}/heartbeat", params={"worker_id": worker_id}
293
+ )
277
294
  raise_for_status(response)
278
295
 
279
296
 
@@ -303,7 +320,7 @@ def ls_workers(
303
320
  worker_id: Optional[str] = None,
304
321
  worker_name: Optional[str] = None,
305
322
  status: Optional[str] = None,
306
- extra_filter: Optional[Dict[str, Any]] = None,
323
+ extra_filter: Optional[Union[str, Dict[str, Any]]] = None,
307
324
  limit: int = 100,
308
325
  offset: int = 0,
309
326
  sort: Optional[List[Tuple[str, int]]] = None,
@@ -312,6 +329,10 @@ def ls_workers(
312
329
  """List workers."""
313
330
  if client is None:
314
331
  client = get_httpx_client()
332
+
333
+ if isinstance(extra_filter, str): # transpile to mongodb query
334
+ extra_filter = transpile_query_safe(query_str=extra_filter)
335
+
315
336
  payload = WorkerLsRequest(
316
337
  worker_id=worker_id,
317
338
  worker_name=worker_name,
@@ -364,7 +385,7 @@ def ls_tasks(
364
385
  task_id: Optional[str] = None,
365
386
  task_name: Optional[str] = None,
366
387
  status: Optional[str] = None,
367
- extra_filter: Optional[Dict[str, Any]] = None,
388
+ extra_filter: Optional[Union[str, Dict[str, Any]]] = None,
368
389
  limit: int = 100,
369
390
  offset: int = 0,
370
391
  sort: Optional[List[Tuple[str, int]]] = None,
@@ -373,6 +394,10 @@ def ls_tasks(
373
394
  """List tasks in a queue."""
374
395
  if client is None:
375
396
  client = get_httpx_client()
397
+
398
+ if isinstance(extra_filter, str): # transpile to mongodb query
399
+ extra_filter = transpile_query_safe(query_str=extra_filter)
400
+
376
401
  payload = TaskLsRequest(
377
402
  task_id=task_id,
378
403
  task_name=task_name,
@@ -34,7 +34,7 @@ from labtasker.client.core.exceptions import (
34
34
  QueryTranspilerError,
35
35
  )
36
36
  from labtasker.client.core.logging import stderr_console
37
- from labtasker.client.core.query_transpiler import transpile_query
37
+ from labtasker.client.core.utils import transpile_query_safe
38
38
  from labtasker.utils import parse_time_interval, unflatten_dict
39
39
 
40
40
  DT = TypeVar("DT")
@@ -84,7 +84,7 @@ def parse_filter(filter_str: Optional[str]) -> Optional[Dict[str, Any]]:
84
84
  return parse_dict(d_str=filter_str)
85
85
  except typer.BadParameter:
86
86
  try:
87
- return transpile_query(query_str=filter_str) # type: ignore[arg-type]
87
+ return transpile_query_safe(query_str=filter_str) # type: ignore[arg-type]
88
88
  except QueryTranspilerError as e:
89
89
  raise typer.BadParameter(f"Invalid filter str: {e}") from e
90
90
 
@@ -176,9 +176,9 @@ class EventListener:
176
176
  self._retry_context_iter = stamina.retry_context(
177
177
  on=httpx.TransportError,
178
178
  attempts=10,
179
- timeout=60,
179
+ timeout=100.0,
180
180
  wait_initial=0.5,
181
- wait_max=8.0,
181
+ wait_max=16.0,
182
182
  wait_jitter=1.0,
183
183
  wait_exp_base=2.0,
184
184
  ).__iter__()
@@ -18,8 +18,9 @@ __all__ = [
18
18
 
19
19
  class Heartbeat:
20
20
 
21
- def __init__(self, task_id, heartbeat_interval):
21
+ def __init__(self, task_id, worker_id, heartbeat_interval):
22
22
  self.task_id = task_id
23
+ self.worker_id = worker_id
23
24
  self.heartbeat_interval = heartbeat_interval
24
25
 
25
26
  self._thread = None
@@ -39,6 +40,7 @@ class Heartbeat:
39
40
  self._thread.start()
40
41
 
41
42
  def delay(self, interval: float) -> bool:
43
+ """Returns False if it should exit."""
42
44
  slice_t = 0.05 # check for stop event
43
45
  start_time = time.perf_counter()
44
46
  while True:
@@ -67,11 +69,12 @@ class Heartbeat:
67
69
  """Refresh heartbeat periodically"""
68
70
  while True:
69
71
  try:
70
- refresh_task_heartbeat(task_id=self.task_id)
72
+ refresh_task_heartbeat(task_id=self.task_id, worker_id=self.worker_id)
71
73
  except Exception as e:
72
- logger.error(f"Heartbeat failed for task {self.task_id}: {e}")
74
+ logger.error(f"Failed to refresh heartbeat: {str(e)}")
75
+ raise
73
76
 
74
- # check if heartbeat should stop
77
+ # Check if heartbeat should stop
75
78
  if not self.delay(self.heartbeat_interval):
76
79
  break
77
80
 
@@ -85,6 +88,9 @@ class Heartbeat:
85
88
  except FileNotFoundError:
86
89
  pass
87
90
 
91
+ def is_alive(self):
92
+ return self._thread and self._thread.is_alive()
93
+
88
94
 
89
95
  _current_heartbeat: ContextVar[Optional[Heartbeat]] = ContextVar(
90
96
  "heartbeat", default=None
@@ -92,7 +98,10 @@ _current_heartbeat: ContextVar[Optional[Heartbeat]] = ContextVar(
92
98
 
93
99
 
94
100
  def start_heartbeat(
95
- task_id, heartbeat_interval: Optional[float] = None, raise_error=True
101
+ task_id,
102
+ worker_id: Optional[str] = None,
103
+ heartbeat_interval: Optional[float] = None,
104
+ raise_error=True,
96
105
  ):
97
106
  logger.debug("Try starting heartbeat.")
98
107
  if _current_heartbeat.get() is not None:
@@ -102,12 +111,14 @@ def start_heartbeat(
102
111
 
103
112
  heartbeat_manager = Heartbeat(
104
113
  task_id=task_id,
114
+ worker_id=worker_id,
105
115
  heartbeat_interval=heartbeat_interval
106
116
  or get_client_config().task.heartbeat_interval,
107
117
  )
108
118
  heartbeat_manager.start()
109
119
  _current_heartbeat.set(heartbeat_manager)
110
120
  logger.debug("Heartbeat started.")
121
+ return heartbeat_manager
111
122
 
112
123
 
113
124
  def end_heartbeat(raise_error=True):
@@ -38,6 +38,7 @@ from labtasker.client.core.exceptions import (
38
38
  from labtasker.client.core.heartbeat import end_heartbeat, start_heartbeat
39
39
  from labtasker.client.core.logging import log_to_file, logger, stderr_console
40
40
  from labtasker.client.core.paths import get_labtasker_log_dir, set_labtasker_log_dir
41
+ from labtasker.client.core.utils import transpile_query_safe
41
42
  from labtasker.utils import parse_time_interval
42
43
 
43
44
  __all__ = [
@@ -106,7 +107,7 @@ def dump_task_info():
106
107
 
107
108
  def loop_run(
108
109
  required_fields: List[str],
109
- extra_filter: Optional[Dict[str, Any]] = None,
110
+ extra_filter: Optional[Union[str, Dict[str, Any]]] = None,
110
111
  cmd: Optional[Union[str, List[str]]] = None,
111
112
  worker_id: Optional[str] = None,
112
113
  create_worker_kwargs: Optional[Dict[str, Any]] = None,
@@ -142,6 +143,9 @@ def loop_run(
142
143
  f"Invalid eta_max {eta_max}. ETA max must be a valid duration string (e.g. '1h', '1h30m', '50s')"
143
144
  )
144
145
 
146
+ if isinstance(extra_filter, str): # transpile to mongodb query
147
+ extra_filter = transpile_query_safe(query_str=extra_filter)
148
+
145
149
  # Check connection and authentication
146
150
  try:
147
151
  get_queue()
@@ -216,7 +220,9 @@ def loop_run(
216
220
  dump_task_info()
217
221
 
218
222
  with log_to_file(file_path=get_labtasker_log_dir() / "run.log"):
219
- start_heartbeat(task_id=current_task_id())
223
+ start_heartbeat(
224
+ task_id=current_task_id(), worker_id=current_worker_id()
225
+ )
220
226
  success_flag = False
221
227
  try:
222
228
  func_args = (task.args, *args) if pass_args_dict else args
@@ -1,6 +1,7 @@
1
1
  import ast
2
+ import difflib
2
3
  from datetime import timezone
3
- from typing import Any, Dict, List, NoReturn, Type
4
+ from typing import Any, Dict, List, NoReturn, Optional, Type
4
5
 
5
6
  import dateparser
6
7
  from rich.console import Console
@@ -154,9 +155,10 @@ class QueryTranspiler(ast.NodeVisitor):
154
155
  ast.Or: "$or", # a or b -> {$or: [a, b]}
155
156
  }
156
157
 
157
- def __init__(self, query_str: str):
158
+ def __init__(self, query_str: str, allowed_fields: Optional[List[str]] = None):
158
159
  super().__init__()
159
160
  self.query_str = query_str
161
+ self.allowed_fields = allowed_fields
160
162
 
161
163
  def _report_error(
162
164
  self, node: ast.AST, msg: str, exception: Type[QueryTranspilerError]
@@ -701,6 +703,20 @@ class QueryTranspiler(ast.NodeVisitor):
701
703
  Python: field_name
702
704
  MongoDB: "field_name" (as a field reference)
703
705
  """
706
+ if self.allowed_fields and node.id not in self.allowed_fields:
707
+ suggestions = difflib.get_close_matches(
708
+ node.id, self.allowed_fields, n=1, cutoff=0.6
709
+ )
710
+ suggestion_msg = (
711
+ f" Maybe you meant '{suggestions[0]}'?" if suggestions else ""
712
+ )
713
+
714
+ self._report_error(
715
+ node=node,
716
+ msg=f"Field '{node.id}' is unknown or not allowed.{suggestion_msg}"
717
+ f"\nAllowed fields: {', '.join(sorted(self.allowed_fields))}",
718
+ exception=QueryTranspilerValueError,
719
+ )
704
720
  return node.id
705
721
 
706
722
  def visit_Attribute(self, node: ast.Attribute) -> str:
@@ -960,7 +976,9 @@ class QueryTranspiler(ast.NodeVisitor):
960
976
  )
961
977
 
962
978
 
963
- def transpile_query(query_str: str) -> Dict[str, Any]:
979
+ def transpile_query(
980
+ query_str: str, allowed_fields: Optional[List[str]] = None
981
+ ) -> Dict[str, Any]:
964
982
  """
965
983
  Transpile a Python-like query string and convert it to a MongoDB query object.
966
984
 
@@ -969,6 +987,7 @@ def transpile_query(query_str: str) -> Dict[str, Any]:
969
987
 
970
988
  Args:
971
989
  query_str: A string containing a Python-like expression to be converted
990
+ allowed_fields: A list of allowed fields (e.g. ast.Name.id). If None, allowed_fields will not be checked.
972
991
 
973
992
  Returns:
974
993
  A dictionary representing the equivalent MongoDB query
@@ -979,7 +998,7 @@ def transpile_query(query_str: str) -> Dict[str, Any]:
979
998
  try:
980
999
  query_str = query_str.strip()
981
1000
  parsed_ast = ast.parse(query_str)
982
- visitor = QueryTranspiler(query_str=query_str)
1001
+ visitor = QueryTranspiler(query_str=query_str, allowed_fields=allowed_fields)
983
1002
  result = visitor.visit(parsed_ast)
984
1003
 
985
1004
  if isinstance(result, bool) or isinstance(result, int):
@@ -3,7 +3,7 @@ import os
3
3
  import subprocess
4
4
  import sys
5
5
  import threading
6
- from functools import wraps
6
+ from functools import partial, wraps
7
7
  from typing import Any, Callable, Optional
8
8
 
9
9
  import httpx
@@ -20,6 +20,7 @@ from labtasker.client.core.exceptions import (
20
20
  )
21
21
  from labtasker.client.core.logging import stderr_console, stdout_console
22
22
  from labtasker.client.core.paths import get_labtasker_client_config_path
23
+ from labtasker.client.core.query_transpiler import transpile_query
23
24
 
24
25
  server_notification_prefix = {
25
26
  "info": "[bold dodger_blue1]INFO(notification):[/bold dodger_blue1] ",
@@ -33,6 +34,31 @@ server_notification_level = {
33
34
  "high": 2,
34
35
  }
35
36
 
37
+ transpile_query_safe = partial(
38
+ transpile_query,
39
+ allowed_fields=[
40
+ "task_id",
41
+ "queue_id",
42
+ "status",
43
+ "task_name",
44
+ "created_at",
45
+ "start_time",
46
+ "last_heartbeat",
47
+ "last_modified",
48
+ "heartbeat_timeout",
49
+ "task_timeout",
50
+ "max_retries",
51
+ "retries",
52
+ "priority",
53
+ "metadata",
54
+ "args",
55
+ "cmd",
56
+ "summary",
57
+ "worker_id",
58
+ "worker_name",
59
+ ],
60
+ )
61
+
36
62
 
37
63
  def json_serializer(obj: Any, **kwargs) -> str:
38
64
  return json.dumps(to_jsonable_python(obj), **kwargs)
@@ -7,5 +7,4 @@ class Priority(int, Enum):
7
7
  HIGH = 20
8
8
 
9
9
 
10
- KEY_PATTERN = r"^[a-zA-Z0-9_-]+$"
11
10
  DOT_SEPARATED_KEY_PATTERN = r"^[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$"
@@ -674,22 +674,52 @@ class DBService:
674
674
  @retry_on_transient
675
675
  @validate_arg
676
676
  def refresh_task_heartbeat(
677
- self,
678
- queue_id: str,
679
- task_id: str,
680
- ) -> bool:
677
+ self, queue_id: str, task_id: str, worker_id: Optional[str] = None
678
+ ):
681
679
  """Update task heartbeat timestamp."""
680
+ query = {"_id": task_id, "queue_id": queue_id, "status": "running"}
681
+
682
682
  with self._client.start_session() as session:
683
683
  with session.start_transaction():
684
- return (
685
- self._tasks.update_one(
686
- {"_id": task_id, "queue_id": queue_id},
687
- {"$set": {"last_heartbeat": get_current_time()}},
688
- session=session,
689
- ).modified_count
690
- > 0
684
+ # Find the task in a single query
685
+ task = self._tasks.find_one(query)
686
+ if not task:
687
+ raise HTTPException(
688
+ status_code=HTTP_404_NOT_FOUND,
689
+ detail=f"Task '{task_id}' not found in queue '{queue_id}' or not in 'running' state",
690
+ )
691
+
692
+ # Validate worker if provided
693
+ if worker_id:
694
+ if task["worker_id"] != worker_id:
695
+ raise HTTPException(
696
+ status_code=HTTP_403_FORBIDDEN,
697
+ detail=f"Task '{task_id}' is assigned to worker '{task['worker_id']}', not '{worker_id}'",
698
+ )
699
+
700
+ # Check worker status in a single query
701
+ worker = self._workers.find_one(
702
+ {"_id": worker_id, "status": WorkerState.ACTIVE}
703
+ )
704
+ if not worker:
705
+ raise HTTPException(
706
+ status_code=HTTP_404_NOT_FOUND,
707
+ detail=f"Worker '{worker_id}' not found or not active",
708
+ )
709
+
710
+ # Update the task heartbeat
711
+ result = self._tasks.update_one(
712
+ query,
713
+ {"$set": {"last_heartbeat": get_current_time()}},
714
+ session=session,
691
715
  )
692
716
 
717
+ if result.modified_count == 0:
718
+ raise HTTPException(
719
+ status_code=HTTP_404_NOT_FOUND,
720
+ detail=f"Failed to update heartbeat for task '{task_id}' - it may have changed state during the operation",
721
+ )
722
+
693
723
  @retry_on_transient
694
724
  @validate_arg
695
725
  def worker_report_task_status(
@@ -1,9 +1,9 @@
1
1
  import asyncio
2
2
  import uuid
3
3
  from contextlib import asynccontextmanager
4
- from typing import Any, Dict, List
4
+ from typing import Any, Dict, List, Optional
5
5
 
6
- from fastapi import Depends, FastAPI, HTTPException, Request
6
+ from fastapi import Depends, FastAPI, HTTPException, Query, Request
7
7
  from sse_starlette.sse import EventSourceResponse
8
8
  from starlette.status import (
9
9
  HTTP_201_CREATED,
@@ -309,16 +309,14 @@ def report_task_status(
309
309
  )
310
310
  def refresh_task_heartbeat(
311
311
  task_id: str,
312
+ worker_id: Optional[str] = Query(None), # use query param
312
313
  queue: Dict[str, Any] = Depends(get_verified_queue_dependency),
313
314
  db: DBService = Depends(get_db),
314
315
  ):
315
316
  """Update task heartbeat timestamp."""
316
- done = db.refresh_task_heartbeat(
317
- queue_id=queue["_id"],
318
- task_id=task_id,
317
+ db.refresh_task_heartbeat(
318
+ queue_id=queue["_id"], task_id=task_id, worker_id=worker_id
319
319
  )
320
- if not done:
321
- raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="Task not found.")
322
320
 
323
321
 
324
322
  @app.get(
@@ -43,7 +43,12 @@ class StateTransitionEventHandle:
43
43
  self.commit()
44
44
 
45
45
  def commit(self):
46
- event_data = StateTransitionEvent(
46
+ event_data = self._create_event_data()
47
+ self._publish_event(event_data)
48
+ self._entity_data = None
49
+
50
+ def _create_event_data(self):
51
+ return StateTransitionEvent(
47
52
  entity_type=self.entity_type,
48
53
  queue_id=self.queue_id,
49
54
  entity_id=self.entity_id,
@@ -54,9 +59,17 @@ class StateTransitionEventHandle:
54
59
  entity_data=self._entity_data,
55
60
  )
56
61
 
62
+ def _publish_event(self, event_data):
57
63
  # Use fully synchronous event publishing
58
64
  event_manager.publish_event(self.queue_id, event_data)
59
- self._entity_data = None
65
+
66
+
67
+ class NullEventHandle(StateTransitionEventHandle):
68
+ """A placeholder that does nothing. (Used for cases where triggering event publishing is undesired)"""
69
+
70
+ def _publish_event(self, event_data):
71
+ # Override to do nothing
72
+ pass
60
73
 
61
74
 
62
75
  class State(str, Enum):
@@ -140,6 +153,18 @@ class BaseFSM:
140
153
  def state(self):
141
154
  return self._state
142
155
 
156
+ def null_transition(self) -> NullEventHandle:
157
+ """Perform a null transition and return a handle"""
158
+ return NullEventHandle(
159
+ entity_type=self.ENTITY_TYPE,
160
+ entity_id=self.entity_id,
161
+ queue_id=self.queue_id,
162
+ old_state=str(self._state),
163
+ new_state=str(self._state),
164
+ transition_time=get_current_time(),
165
+ metadata=self.metadata,
166
+ )
167
+
143
168
  def transition_to(self, new_state: State) -> StateTransitionEventHandle:
144
169
  """Perform state transition and return a handle"""
145
170
  old_state = self._state
@@ -190,6 +215,7 @@ class TaskFSM(BaseFSM):
190
215
  TaskState.FAILED: {
191
216
  TaskState.PENDING,
192
217
  TaskState.CANCELLED,
218
+ TaskState.FAILED, # null transition (for more tolerance)
193
219
  }, # Can be reset and requeued
194
220
  TaskState.CANCELLED: {
195
221
  TaskState.PENDING,
@@ -280,11 +306,15 @@ class TaskFSM(BaseFSM):
280
306
  Transitions:
281
307
  - RUNNING -> PENDING (if retries < max_retries)
282
308
  - RUNNING -> FAILED (if retries >= max_retries)
309
+ - FAILED -> FAILED (null transition, does nothing)
283
310
  - Others -> InvalidStateTransition (invalid)
284
311
 
285
312
  Note: FAILED state can transition back to PENDING for retries
286
313
  until max_retries is reached.
287
314
  """
315
+ if self.state == TaskState.FAILED:
316
+ return self.null_transition()
317
+
288
318
  if self.state != TaskState.RUNNING:
289
319
  raise InvalidStateTransition(f"Cannot fail task in {self.state} state")
290
320
 
@@ -305,7 +335,10 @@ class WorkerFSM(BaseFSM):
305
335
  WorkerState.CRASHED,
306
336
  },
307
337
  WorkerState.SUSPENDED: {WorkerState.ACTIVE}, # Manual transition
308
- WorkerState.CRASHED: {WorkerState.ACTIVE}, # Manual transition
338
+ WorkerState.CRASHED: {
339
+ WorkerState.ACTIVE, # Manual transition
340
+ WorkerState.CRASHED, # null transition (for more tolerance)
341
+ },
309
342
  }
310
343
 
311
344
  def __init__(
@@ -366,7 +399,11 @@ class WorkerFSM(BaseFSM):
366
399
  Transitions:
367
400
  - ACTIVE -> ACTIVE
368
401
  - ACTIVE -> CRASHED (retries >= max_retries)
402
+ - CRASHED -> CRASHED (null transition, does nothing)
369
403
  """
404
+ if self.state == WorkerState.CRASHED:
405
+ return self.null_transition()
406
+
370
407
  if self.state != WorkerState.ACTIVE:
371
408
  raise InvalidStateTransition(f"Cannot fail worker in {self.state} state")
372
409
 
@@ -154,7 +154,8 @@ tests/test_client/test_cli/test_task.py
154
154
  tests/test_client/test_cli/test_worker.py
155
155
  tests/test_client/test_core/__init__.py
156
156
  tests/test_client/test_core/test_cli_utils.py
157
- tests/test_client/test_core/test_heartbeat.py
157
+ tests/test_client/test_core/test_heartbeat_e2e.py
158
+ tests/test_client/test_core/test_heartbeat_unit.py
158
159
  tests/test_client/test_core/test_job_runner.py
159
160
  tests/test_client/test_core/test_logging.py
160
161
  tests/test_client/test_core/test_loop_internal_error_handler.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "labtasker"
7
- version = "0.2.10"
7
+ version = "0.2.12"
8
8
  description = "A task queue system for lab experiments"
9
9
  authors = [{ name = "Your Name", email = "your.email@example.com" }]
10
10
  license = { text = "Apache License 2.0" }
@@ -64,6 +64,7 @@ dev = [
64
64
  "tox (>=4.24.0,<4.29.0)",
65
65
  "pytest-dependency (>=0.6.0,<0.7.0)",
66
66
  "pytest-sugar (>=1.0.0,<2.0.0)",
67
+ "rust-just (>=1.42.4,<2.0.0)",
67
68
  ]
68
69
  doc = [
69
70
  "mkdocs-material (>=9.6.5,<9.7.0)",
@@ -132,7 +133,6 @@ disable_error_code = [
132
133
  "no-redef",
133
134
  "import-untyped"
134
135
  ]
135
- python_version = "3.10"
136
136
  warn_unused_configs = true
137
137
  ignore_missing_imports = true
138
138
  show_error_codes = true