From f67de2f6a2ade81e1665687120cab0771018327c Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 17:52:41 +0900 Subject: [PATCH 01/14] adjust test codes and test datas location --- tests/log/test.log.template.yml | 24 - tests/src/win/__init__.py | 0 tests/src/win/_test_PzLog.py | 173 ------- tests/src/win/test_PzTask.py | 76 --- tests/src/win/test_puzzle.py | 471 ------------------ tests/tasks/__init__.py | 0 tests/tasks/win/__init__.py | 0 tests/tasks/win/add_specified_data.py | 15 - tests/tasks/win/add_sys_path_test/__init__.py | 0 .../win/add_sys_path_test/other_location.py | 19 - tests/tasks/win/append_reference.py | 22 - tests/tasks/win/bake_all.py | 32 -- tests/tasks/win/change_frame.py | 60 --- tests/tasks/win/export_file.py | 46 -- tests/tasks/win/get_from_scene.py | 24 - tests/tasks/win/get_from_scene_empty.py | 24 - ...et_from_scene_empty_break_on_conditions.py | 21 - tests/tasks/win/goto_frame.py | 28 -- tests/tasks/win/import_file.py | 28 -- tests/tasks/win/mock.py | 34 -- tests/tasks/win/open_file.py | 62 --- tests/tasks/win/preview.py | 14 - tests/tasks/win/rename_namespace.py | 35 -- .../tasks/win/rename_namespace_with_error.py | 15 - tests/tasks/win/revert.py | 32 -- tests/tasks/win/save_file.py | 39 -- tests/tasks/win/submit_to_sg.py | 31 -- 27 files changed, 1325 deletions(-) delete mode 100644 tests/log/test.log.template.yml delete mode 100644 tests/src/win/__init__.py delete mode 100644 tests/src/win/_test_PzLog.py delete mode 100644 tests/src/win/test_PzTask.py delete mode 100644 tests/src/win/test_puzzle.py delete mode 100644 tests/tasks/__init__.py delete mode 100644 tests/tasks/win/__init__.py delete mode 100644 tests/tasks/win/add_specified_data.py delete mode 100644 tests/tasks/win/add_sys_path_test/__init__.py delete mode 100644 tests/tasks/win/add_sys_path_test/other_location.py delete mode 100644 tests/tasks/win/append_reference.py delete mode 100644 tests/tasks/win/bake_all.py delete mode 100644 tests/tasks/win/change_frame.py delete mode 100644 tests/tasks/win/export_file.py delete mode 100644 tests/tasks/win/get_from_scene.py delete mode 100644 tests/tasks/win/get_from_scene_empty.py delete mode 100644 tests/tasks/win/get_from_scene_empty_break_on_conditions.py delete mode 100644 tests/tasks/win/goto_frame.py delete mode 100644 tests/tasks/win/import_file.py delete mode 100644 tests/tasks/win/mock.py delete mode 100644 tests/tasks/win/open_file.py delete mode 100644 tests/tasks/win/preview.py delete mode 100644 tests/tasks/win/rename_namespace.py delete mode 100644 tests/tasks/win/rename_namespace_with_error.py delete mode 100644 tests/tasks/win/revert.py delete mode 100644 tests/tasks/win/save_file.py delete mode 100644 tests/tasks/win/submit_to_sg.py diff --git a/tests/log/test.log.template.yml b/tests/log/test.log.template.yml deleted file mode 100644 index a7d92d6..0000000 --- a/tests/log/test.log.template.yml +++ /dev/null @@ -1,24 +0,0 @@ -info: - name: log.template - -data: - version: 1 - disable_existing_loggers: false - formatters: - simple_formatter: - format: '%(asctime)-25s %(levelname)-10s %(module)-20s %(message)s' - datefmt: '%m-%d-%y %H:%M' - - handlers: - stream_handler: - class: logging.StreamHandler - level: DEBUG - formatter: simple_formatter - stream: ext://sys.stdout - - file_handler: - class: logging.FileHandler - level: DEBUG - formatter: simple_formatter - filename: logfile.log - mode: a \ No newline at end of file diff --git a/tests/src/win/__init__.py b/tests/src/win/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/src/win/_test_PzLog.py b/tests/src/win/_test_PzLog.py deleted file mode 100644 index 6f68ead..0000000 --- a/tests/src/win/_test_PzLog.py +++ /dev/null @@ -1,173 +0,0 @@ -import unittest -import os -import sys -import logging - - -from puzzle2.PzLog import PzLog -import puzzle2.pz_config as pz_config -import unittest - -class PzLogUseDefaultTemplate(unittest.TestCase): - def setUp(self): - """ - The 'reset_template' param always uses the default 'template.yml' file. - """ - logging.Logger.manager.loggerDict.clear() - self.pz_log = PzLog("test", reset_template=True) - - def test_check_template(self): - self.assertEqual(os.path.basename(self.pz_log.base_template_path), "log.template.yml") - for handler in self.pz_log.logger.handlers: - if handler.name == "stream_handler": - self.assertEqual(handler.level, logging.DEBUG) - - def tearDown(self): - self.pz_log.remove_handlers() - - -class PzLogUseExistsTemplate(unittest.TestCase): - def setUp(self): - """ - create a default template logger.(pz_logA) - then change level for next logger, with have same name. - "new" param makes reflesh handlers. - pz_logB will read template file from pz_logA's template file. - but stream handler level will be changed to CRITICAL. - """ - logging.Logger.manager.loggerDict.clear() - - self.pz_logA = PzLog("test2") - info, config = pz_config.read(self.pz_logA.base_template_path) - config["handlers"]["stream_handler"]["level"] = "CRITICAL" - pz_config.save(self.pz_logA.base_template_path, config, {}) - - self.pz_logB = PzLog("test2", new=True, reset_template=False) - - def test_check_template(self): - """ - WARNING: - in ths case, pz_logA's stream handler level will be changed to WARNING too. - pz_logA and pz_logB have same logger object. - """ - - # check template file name.this is same as pz_logA's template file. - self.assertEqual(os.path.basename(self.pz_logB.base_template_path), "test2.json") - - for handler in self.pz_logA.logger.handlers: - if handler.name == "stream_handler": - self.assertEqual(handler.level, logging.CRITICAL) - - for handler in self.pz_logB.logger.handlers: - if handler.name == "stream_handler": - self.assertEqual(handler.level, logging.CRITICAL) - - def tearDown(self): - self.pz_logA.remove_handlers() - self.pz_logB.remove_handlers() - -class PzLogUseExistsTemplateAddingDateToLogName(unittest.TestCase): - def setUp(self): - """ - The "add_date_to_log_name" parameter creates a log file name that includes the date. - If the day changes, a different date log will be used, based on the existing template file. - In this case, the existing template file will not be used. - """ - logging.Logger.manager.loggerDict.clear() - self.pz_logA = PzLog("test3", add_date_to_log_name=True) - self.pz_logB = PzLog("test3", new=True, reset_template=False, add_date_to_log_name=True) - - def test_check_template(self): - """ - simmilier to PzLogUseExistsTemplate but add_date_to_log_name is True. - so, default template.yml will be used. - """ - self.assertEqual(os.path.basename(self.pz_logB.base_template_path), "log.template.yml") - for handler in self.pz_logB.logger.handlers: - if handler.name == "stream_handler": - self.assertEqual(handler.level, logging.DEBUG) - - def tearDown(self): - self.pz_logA.remove_handlers() - self.pz_logB.remove_handlers() - -class PzLogUtility(unittest.TestCase): - def test_check_log_file_name(self): - logging.Logger.manager.loggerDict.clear() - self.pz_logA = PzLog("test1", add_date_to_log_name=True) - import datetime - now = datetime.datetime.now().strftime("%Y%m%d") - for handler in self.pz_logA.logger.handlers: - if handler.name == "file_handler": - self.assertEqual(os.path.basename(handler.baseFilename), "{}_test1.log".format(now)) - - def test_check_log_file_name02(self): - logging.Logger.manager.loggerDict.clear() - self.pz_logA = PzLog("test1", add_date_to_log_name=True, user_name="name") - import datetime - now = datetime.datetime.now().strftime("%Y%m%d") - for handler in self.pz_logA.logger.handlers: - if handler.name == "file_handler": - self.assertEqual(os.path.basename(handler.baseFilename), "{}_test1_name.log".format(now)) - - def test_update_level(self): - logging.Logger.manager.loggerDict.clear() - self.pz_logA = PzLog("test4", file_handler_level="CRITICAL", stream_handler_level="CRITICAL") - - for handler in self.pz_logA.logger.handlers: - self.assertEqual(handler.level, logging.CRITICAL) - - def tearDown(self): - self.pz_logA.remove_handlers() - - -class PzLogDetail(unittest.TestCase): - def setUp(self): - self.pz_logA = PzLog("test5") - self.logger = self.pz_logA.logger - - def test_add_details(self): - self.logger.details.set_name("task_name1") - name1 = self.logger.details.name - - self.logger.details.add_detail("test1") - self.logger.details.add_detail("test2") - self.logger.details.add_detail("test3") - self.logger.details.set_header(0, "first task") - - self.logger.details.set_name("task_name2") - name2 = self.logger.details.name - self.logger.details.add_detail("test4") - self.logger.details.add_detail("test5") - self.logger.details.add_detail("test6") - self.logger.details.set_header(1, "secound task") - - self.assertEqual(self.logger.details.get_details(name1), ["test1", "test2", "test3"]) - self.assertEqual(self.logger.details.get_details(name2), ["test4", "test5", "test6"]) - - self.assertEqual(self.logger.details.get_details(), [["test1", "test2", "test3"], - ["test4", "test5", "test6"]]) - - self.assertEqual(self.logger.details.get_return_codes(), [0, 1]) - - self.assertEqual(self.logger.details.get_all(), [{"return_code": 0, - "header": "first task", - "details": ["test1", "test2", "test3"], - "meta_data": {} - }, - {"return_code": 1, - "header": "secound task", - "details": ["test4", "test5", "test6"], - "meta_data": {}} - ]) - - self.logger.details.clear() - - self.assertEqual(self.logger.details.get_header(), []) - self.assertEqual(self.logger.details.get_details(), []) - - def tearDown(self): - self.pz_logA.remove_handlers() - -if __name__ == "__main__": - unittest.main() diff --git a/tests/src/win/test_PzTask.py b/tests/src/win/test_PzTask.py deleted file mode 100644 index 7170c6a..0000000 --- a/tests/src/win/test_PzTask.py +++ /dev/null @@ -1,76 +0,0 @@ -import unittest -import os -import sys - -from puzzle2.PzTask import PzTask - -module_path = os.path.normpath(os.path.join(__file__, "../../../")) -sys.path.append(module_path) - -import tasks.win.mock as mock - -class TaskFunctionTest(unittest.TestCase): - def test_key_required(self): - data = {} - pz_task = PzTask(module=mock, data=data) - response = pz_task.execute() - self.assertEqual(response["return_code"], 5) - - data = { - "name": "nameA" - } - pz_task = PzTask(module=mock, data=data) - response = pz_task.execute() - self.assertEqual(response["return_code"], 0) - - def test_data_key_replace(self): - data = {"new_name": "nameB"} - task = {"data_key_replace": { - "name": "new_name" - }} - - pz_task = PzTask(module=mock, task=task, data=data) - - self.assertEqual(pz_task.data["name"], data["new_name"]) - - def test_data_key_replace_from_other_task(self): - data = {"name": "nameA"} - task = {"data_key_replace": { - "name": "context.new_name" - }} - - context = {"new_name": "nameB"} - pz_task = PzTask(module=mock, task=task, data=data, context=context) - self.assertEqual(pz_task.data["name"], context["new_name"]) - - def test_conditions(self): - data = {"name": "nameA", "category": "ch"} - task = {"conditions": [{"category": "ch"}]} - - pz_task = PzTask(module=mock, task=task, data=data) - self.assertEqual(pz_task.return_code, 0) - - data = {"name": "nameA", "category": "prop"} - task = {"conditions": [{"category": "ch"}]} - - pz_task = PzTask(module=mock, task=task, data=data) - self.assertEqual(pz_task.return_code, 2) - - data = {"name": "nameA", "category": "ch"} - task = {"conditions": [{"category": "ch", "name": "nameA"}]} - - pz_task = PzTask(module=mock, task=task, data=data) - self.assertEqual(pz_task.return_code, 0) - - - data = {"name": "nameA", "category": "ch"} - task = {"conditions": [{"category": "ch", "name": "nameB"}]} - - pz_task = PzTask(module=mock, task=task, data=data) - self.assertEqual(pz_task.return_code, 2) - - -if __name__ == "__main__": - unittest.main() - - diff --git a/tests/src/win/test_puzzle.py b/tests/src/win/test_puzzle.py deleted file mode 100644 index 8d2f2dd..0000000 --- a/tests/src/win/test_puzzle.py +++ /dev/null @@ -1,471 +0,0 @@ -import unittest -import os -import sys - -from puzzle2.Puzzle import Puzzle - -module_path = os.path.normpath(os.path.join(__file__, "../../../")) -sys.path.append(module_path) - -# set debug if you want to see log detail -LOGGER_LEVEL = "DEBUG" - - -class PuzzleTestAndTutorial(unittest.TestCase): - def setUp(self): - print("") - self.puzzle = Puzzle(logger_level=LOGGER_LEVEL, new=True, reset_template=True) - - def test_simple(self): - """ - tasks are all OK. - """ - print("test_simple") - - steps = [{"step": "pre", "tasks": [{"module": "tasks.win.open_file"}]}, - {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}] - - data = {"pre": {"open_path": "somewhere"}, - "main": [{"name": "nameA"}, {"name": "nameB"}]} - - self.puzzle.play(steps, data) - - self.assertEqual(self.puzzle.logger.details.get_return_codes(), [0, 0, 0]) - - def test_task_failed_but_keep_running(self): - """ - there are error but tasks keep running - """ - - - print("test_task_failed_but_keep_running") - - - steps = [{"step": "pre", - "tasks": [ - {"module": "tasks.win.open_file"} - ] - }, - {"step": "main", - "tasks": [ - {"module": "tasks.win.export_file"} - ] - }] - - data = {"pre": {"path": "somewhere"}, # "open_path" is required but not exists. - "main": [{"name": "nameA"}, {"name": "nameB"}]} - - self.puzzle.play(steps, data) - self.assertEqual(self.puzzle.logger.details.get_return_codes(), [5, 0, 0]) - - def test_task_failed_then_stopped(self): - """ - there are error and break_on_exceptions key exists. - tasks stopped. - """ - - print("test_task_failed_then_stopped") - - - steps = [{"step": "pre", "tasks": [{"module": "tasks.win.open_file", "break_on_exceptions": True}]}, - {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}] - - data = {"pre": {"path": "somewhere"}, # "open_path" is requeired but not exists.then task stopped. - "main": [{"name": "nameA"}, {"name": "nameB"}]} - - self.puzzle.play(steps, data) - self.assertEqual(self.puzzle.logger.details.get_return_codes(), [5]) - - def test_task_failed_stop_but_closure_is_executed(self): - """ - there are error and break_on_exceptions key exists. - tasks stopped. - but special step "closure" will executed. - """ - - print("test_task_failed_stop_but_closure_is_executed") - - - steps = [{"step": "pre", "tasks": [{"module": "tasks.win.open_file", "break_on_exceptions": True}]}, # stop here - {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, # skip(with no return code) - {"step": "closure", "tasks": [{"module": "tasks.win.revert"}]}] # closure runs - - data = {"pre": {"path": "somewhere"}, - "main": [{"name": "nameA"}, {"name": "nameB"}], - "common": {"revert": {"a": 1}}} - - self.puzzle.play(steps, data) - self.assertEqual(self.puzzle.logger.details.get_return_codes(), [5, 0]) - - def test_init_flow(self): - """ - sometime we want generate data from current scene - "init" key is special step for that case. - """ - - print("test_init_flow") - - steps = [{"step": "init", "tasks": [{"module": "tasks.win.open_file"}, - {"module": "tasks.win.get_from_scene"}]}, # generate main data - {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}] - - data = {"init": {"open_path": "somewhere"}} # data override by "get_from_scene" context - # add 3 main data inside "init" - # task runs 2(init) + 3(main) times.runturn_code must be five 0 - - self.puzzle.play(steps, data) - - return_codes = self.puzzle.logger.details.get_return_codes() - self.assertEqual([0, 0, 0, 0, 0], return_codes) - - def test_update_data_and_use_it_in_other_task(self): - """ - when we want to export different name from first. - set data_key_replace key to task. then use key to at 'export_file'. - - IMPORTANT: - if step is loop. context[data] value will overrided. - list will replace all. - dict will updated. - if we want to use context[data] at next step, we can add to list but we must - get value from context[data] then add it to them. - - this is closly breaking rules. but it depend on us. - check export_file script. - """ - - print("test_update_data_and_use_it_in_other_task") - steps = [{"step": "pre", - "tasks": [{"module": "tasks.win.open_file", "break_on_exceptions": True}]}, - {"step": "main", - "tasks": [{"module": "tasks.win.import_file"}, - {"module": "tasks.win.rename_namespace"}, # rename - {"module": "tasks.win.export_file", # use new name - "data_key_replace": { - "name": "context.rename_namespace.new_name" - } # then add name to the list - } - ] - }, - {"step": "post", - "tasks": [{"module": "tasks.win.submit_to_sg", - "data_key_replace": { - "assets": "context.export_file.export_names" - }}] - }] - - data = {"pre": {"open_path": "somewhere"}, - "main": [{"name": "nameA", "path": "somewhere a"}, - {"name": "nameB", "path": "somewhere b"}], - "common": { - "shot_code": "ep000_s000_c000", - "fps": 24, - "start_frame": 101, - "end_frame": 200 - - } - } - - self.puzzle.play(steps, data) - # self.puzzle.context["rename_namespace.new_name"] will overrided in the loop, - # so we only could get the last one. - self.assertEqual(self.puzzle.context["rename_namespace.new_name"], "nameB_01") - - # when we need to get all values from loop.we have to use trick in scripts. - # but it possible. - export_names = self.puzzle.context["export_file.export_names"] - self.assertEqual(["nameA_01", "nameB_01"], export_names) - - def test_data_defaults(self): - """ - we can add default values at task setting - in this case, data is blank but default sets 100. - result is 100 - """ - - print("test_data_defaults") - - steps = [{"step": "main", "tasks": [{"module": "tasks.win.add_specified_data", - "data_defaults": {"add": 100} - }] - }] - - data = {"main": {}} - - self.puzzle.play(steps, data) - self.assertEqual(self.puzzle.context["add_specified_data.add"], 100) - - """ - if we have add key in data. - result is 200 - """ - data = {"main": {"add": 200}} - - self.puzzle.play(steps, data) - self.assertEqual(self.puzzle.context["add_specified_data.add"], 200) - - def test_data_override(self): - """ - we can add override values at task setting - in this case, data is blank but override value is 100. - result is 100 - """ - - print("test_data_override") - - steps = [{"step": "main", "tasks": [{"module": "tasks.win.add_specified_data", - "data_override": {"add": 100} - }] - }] - - data = {"main": {}} - - self.puzzle.play(steps, data) - self.assertEqual(self.puzzle.context["add_specified_data.add"], 100) - - """ - if we have add key in data. - but override exists. result is 100 - """ - data = {"main": {"add": 50}} - - self.puzzle.play(steps, data) - self.assertEqual(self.puzzle.context["add_specified_data.add"], 100) - -class PuzzleTest(unittest.TestCase): - def setUp(self): - print("") - self.puzzle = Puzzle(logger_level=LOGGER_LEVEL, new=True, reset_template=True) - - def test_skip_flow(self): - """ - if every thing skipped - what happend to context[_data]? - set 'main' at get_from_scene. - it will pass through every task till last. - """ - - print("test_skip_flow") - - steps = [{"step": "pre", - "tasks": [ - {"module": "tasks.win.get_from_scene"} # {"main": [{"name": "a"}, {"name": "b"}, {"name": "c"}]} - ] - }, - {"step": "main", - "tasks": [ - {"module": "tasks.win.import_file", - "conditions": [{"test": ""}]}, - {"module": "tasks.win.rename_namespace", - "conditions": [{"test": ""}]}, - {"module": "tasks.win.export_file", - "conditions": [{"test": ""}], - "data_key_replace": { - "name": "context.rename_namespace.new_name" - } - } - ] - }, - {"step": "post", "tasks": [{"module": "tasks.win.submit_to_sg", - "conditions": [{"test": ""}], - "data_key_replace": { - "assets": "context.export_file.export_names" - }}] - }] - - data = {} - - self.puzzle.play(steps, data) - - names = [l["name"] for l in self.puzzle.context["main"]] - self.assertEqual("a,b,c", ",".join(names)) - - def test_init_is_blank_then_break(self): - """ - when init data is empty or not exactry what we want. - stop progress when break_on_exceptions flag is True and set return code != [0, 2] - """ - - print("test_init_is_blank_then_break") - - steps = [{"step": "init", - "tasks": [{"module": "tasks.win.open_file"}, # 0 - {"module": "tasks.win.get_from_scene_empty", # 1 - "break_on_exceptions": True}]}, - {"step": "main", - "tasks": [{"module": "tasks.win.export_file"}]}] # this tasks will skipped - - data = {"init": {"open_path": "somewhere"}} - self.puzzle.play(steps, data) - - return_codes = self.puzzle.logger.details.get_return_codes() - self.assertEqual([0, 1], return_codes) - - def test_init_is_nothing(self): - """ - when init data is empty but task need to run. - do not set break_on_exceptions flag True - """ - - print("test_init_is_nothing") - - steps = [{"step": "init", "tasks": [{"module": "tasks.win.open_file"}, # 0 - {"module": "tasks.win.get_from_scene_empty"}]}, # 1 - {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, # this tasks will skipped. - {"step": "post", "tasks": [{"module": "tasks.win.export_file"}]}] # 0 - - data = {"init": {"open_path": "somewhere"}, - "post": {"name": "somewhere"}} # data override from "get_from_scene" - - self.puzzle.play(steps, data) - - return_codes = self.puzzle.logger.details.get_return_codes() - self.assertEqual([0, 1, 0], return_codes) - - def test_init_is_nothing_and_break_safely(self): - """ - when init data is empty, just stop all tasks. - add 'break_on_conditions' to response. - """ - - print("test_init_is_nothing") - - steps = [{"step": "init", "tasks": [{"module": "tasks.win.open_file"}, # 0 - {"module": "tasks.win.get_from_scene_empty_break_on_conditions"}]}, # 0, break here - {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, # skip - {"step": "post", "tasks": [{"module": "tasks.win.export_file"}]}] # skip - - data = {"init": {"open_path": "somewhere"}, - "post": {"name": "somewhere"}} # data override from "get_from_scene" - - self.puzzle.play(steps, data) - - return_codes = self.puzzle.logger.details.get_return_codes() - self.assertEqual([0, 0], return_codes) - - def test_conditions_skip_and_break_on_exceptions_true(self): - """ - task run when category is chara. - skip with condition is not error, so every task will run fine. - """ - - - print("test_conditions_skip_and_force_true") - steps = [{ - "step": "main", "tasks": [ - {"module": "tasks.win.rename_namespace", - "conditions": [{"category": "chara"}], - "break_on_exceptions": True - } - ] - }] - - data = {"main": [{"category": "chara", "name": "charaA"}, # 0 - {"category": "chara", "name": "charaB"}, # 0 - {"categery": "bg", "name": "bgA"}, # 2 > skip - {"categery": "bg", "name": "bgB"}] # 2 > skip - } - - self.puzzle.play(steps, data) - - return_codes = self.puzzle.logger.details.get_return_codes() - self.assertEqual([0, 0, 2, 2], return_codes) - - - def test_break_on_exceptions_is_true_and_error_occur(self): - - """ - task run when category is bg.but some error inside script. - then stop all because break_on_exceptions key is True. - """ - - - print("test_break_on_exceptions_is_true_and_error_occur") - steps = [{ - "step": "main", - "tasks": [ - {"module": "tasks.win.rename_namespace_with_error", - "conditions": [{"categery": "bg"}], - "break_on_exceptions": True - } - ] - }] - - # tasks will stop at bg category because of script error - data = {"main": [{"category": "chara", "name": "charaA"}, # 2 skip task - {"category": "chara", "name": "charaB"}, # 2 skip task - {"categery": "bg", "name": "bgA"}, # 4 error, break - {"categery": "bg", "name": "bgB"}] - } - - self.puzzle.play(steps, data) - - return_codes = self.puzzle.logger.details.get_return_codes() - self.assertEqual([2, 2, 4], return_codes) - - def test_add_location(self): - """ - add sys_path to step - """ - steps = [{ - "step": "main", - "sys_path": "{}/tasks/win/add_sys_path_test".format(module_path), - "tasks": [ - {"module": "other_location"} - ] - }] - - # tasks will stop at bg category because of script error - data = {} - - self.puzzle.play(steps, data) - - return_codes = self.puzzle.logger.details.get_return_codes() - self.assertEqual([0], return_codes) - - - def test_step_in_step(self): - """ - we can nest step in step. - """ - steps = [ - {"step": "pre", - "tasks": [ - {"module": "tasks.win.open_file"} - ]}, - { - "step": "main", - "sys_path": "{}/tasks/win/add_sys_path_test".format(module_path), - "tasks": [ - {"step": "main.pre", - "tasks": [ - {"module": "tasks.win.append_reference"}, - {"module": "tasks.win.rename_namespace"}, - {"module": "tasks.win.import_file"} - ]}, - {"module": "tasks.win.preview"} - ] - }] - - - data = {"pre": {"open_path": "somewhere"}, # 1 - "main": { - "main.pre": [ - {"category": "chara", "name": "charaA", "path": "/charaA.ma"}, # 2,3,4 - {"category": "chara", "name": "charaB", "path": "/charaB.ma"}, # 5,6,7 - {"category": "chara", "name": "charaC", "path": "/charaC.ma"}, # 8,9,10 - {"category": "bg", "name": "BgA", "path": "/BgA.ma"} # 11,12,13 - ], - "category": "chara", "path": "/test.mov"} # 14 - } - - self.puzzle.play(steps, data) - return_codes = self.puzzle.logger.details.get_return_codes() - import pprint - pprint.pprint(self.puzzle.context) - - self.assertEqual([0] * 14, return_codes) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tasks/__init__.py b/tests/tasks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/tasks/win/__init__.py b/tests/tasks/win/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/tasks/win/add_specified_data.py b/tests/tasks/win/add_specified_data.py deleted file mode 100644 index 13bd121..0000000 --- a/tests/tasks/win/add_specified_data.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*-coding: utf8-*- -import os -from puzzle2.PzLog import PzLog - -PIECE_NAME = "add_specified_data" - -def main(event={}, context={}): - data = event["data"] - update_context = {} - update_context["{}.add".format(PIECE_NAME)] = data["add"] - return {"update_context": update_context} - -if __name__ == "__main__": - event = {"data": {"add": 1}} - main(event) diff --git a/tests/tasks/win/add_sys_path_test/__init__.py b/tests/tasks/win/add_sys_path_test/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/tasks/win/add_sys_path_test/other_location.py b/tests/tasks/win/add_sys_path_test/other_location.py deleted file mode 100644 index cfc2f38..0000000 --- a/tests/tasks/win/add_sys_path_test/other_location.py +++ /dev/null @@ -1,19 +0,0 @@ -from puzzle2.PzLog import PzLog - -TASK_NAME = "other_location" - -def main(event={}, context={}): - data = event.get("data", {}) - - logger = context.get("logger") - if not logger: - logger = PzLog().logger - - return_code = 0 - - return {"return_code": return_code} - - -if __name__ == "__main__": - data = {"": ""} - main(event={"data": data}) \ No newline at end of file diff --git a/tests/tasks/win/append_reference.py b/tests/tasks/win/append_reference.py deleted file mode 100644 index eaa7256..0000000 --- a/tests/tasks/win/append_reference.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*-coding: utf8-*- -import os -import copy -from puzzle2.PzLog import PzLog - -TASK_NAME = "append_reference" - -def main(event={}, context={}): - data = event["data"] - print("append reference: {}".format(data["name"])) - key = "{}.update_context_test".format(TASK_NAME) - update_context = {} - if key in context: - update_context[key] = copy.deepcopy(context[key]) - update_context[key].append(data["name"]) - else: - update_context = {key: [data["name"]]} - return {"return_code": 0, "update_context": update_context} - -if __name__ == "__main__": - event = {"data": {"add": 1}} - main(event) diff --git a/tests/tasks/win/bake_all.py b/tests/tasks/win/bake_all.py deleted file mode 100644 index 5ad623a..0000000 --- a/tests/tasks/win/bake_all.py +++ /dev/null @@ -1,32 +0,0 @@ -from puzzle2.PzLog import PzLog - -TASK_NAME = "bake_all" -DATA_KEY_REQUIRED = ["assets"] - -def main(event={}, context={}): - """ - bake all assets - - key required from data: - assets: list - """ - - data = event.get("data", {}) - task = event.get("task", {}) - data_globals = event.get("data_globals", {}) - - logger = context.get("logger") - if not logger: - logger = PzLog().logger - - return_code = 0 - - - - - return {"return_code": return_code, "data_globals": data_globals} - - -if __name__ == "__main__": - data = {"assets": [{"name": "a"}, {"name": "b"}]} - main(event={"data": data}) \ No newline at end of file diff --git a/tests/tasks/win/change_frame.py b/tests/tasks/win/change_frame.py deleted file mode 100644 index c99134a..0000000 --- a/tests/tasks/win/change_frame.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/python -# -*- coding:utf-8 -*- -# ****** SETTINGS START (DO NOT TOUCH) ****** -# PRESET TYPE: PY -# PRESET ICON: change_frame.png -# PRESET NAME: Clean Unknown Plugins -# PRESET COLOR: 0,168,120 -# DESCRIPTION: フレーム変更 -# BACKUP FILE: Yes -# SAVEAS FILE: No -# SAVE FILE: Yes -# OPEN AS: open -# ****** SETTINGS END (DO NOT TOUCH) ****** - -import time - -TASK_NAME = "ChangeFrame" -from puzzle2.PzLog import PzLog - -def main(event={}, context={}): - data = event.get("data", {}) - task = event.get("task", {}) - update_context = {} - logger = context.get("logger") - if not logger: - logger = PzLog().logger - return_code = 0 - - logger.info("frame is: {}".format(data["frame"])) - - frame = data["frame"] + 100 - update_context["frame"] = frame - update_context["XXXXX"] = "abcde" - - logger.info("set frame to: {}(info)".format(frame)) - logger.debug("set frame to: {}(debug)".format(frame)) - - logger.details.add_detail("test details") - logger.details.set_header(0, "change frame to: {}".format(frame)) - - # logger.error("ERROR occured!") - # logger.warning("Oops, something is wrong...") - # logger.success(ui, "FINISHED!") - # logger.updateUI(ui, "Updated!", level="RESULT") - - update_context["{}.data_globals_test".format(TASK_NAME)] = TASK_NAME - return {"return_code": return_code, "update_context": update_context} - -if __name__ == "__main__": - # from config file - task = {"name": "hoge", "a": 2, "paint": {"frame": "@frame"}} - - # data - data = {"frame": 156789} - - # from previus task - - event = {"task": task, "data": data} - - main(event) diff --git a/tests/tasks/win/export_file.py b/tests/tasks/win/export_file.py deleted file mode 100644 index 28a9394..0000000 --- a/tests/tasks/win/export_file.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/python -# -*- coding:utf-8 -*- -# ****** SETTINGS START (DO NOT TOUCH) ****** -# PRESET TYPE: PY -# PRESET ICON: change_frame.png -# PRESET NAME: Clean Unknown Plugins -# PRESET COLOR: 0,168,120 -# DESCRIPTION: フレーム変更 -# BACKUP FILE: Yes -# SAVEAS FILE: No -# SAVE FILE: Yes -# OPEN AS: open -# ****** SETTINGS END (DO NOT TOUCH) ****** - -import time - -TASK_NAME = "export_file" -from puzzle2.PzLog import PzLog - -def main(event={}, context={}): - data = event.get("data", {}) - update_context = {} - logger = context.get("logger") - if not logger: - logger = PzLog().logger - - return_code = 0 - logger.debug("export: {}".format(data["name"])) - logger.details.add_detail("test details") - - # append value to previous task's value. - export_names = context.get("{}.export_names".format(TASK_NAME), []) - export_names.append(data["name"]) - update_context["{}.export_names".format(TASK_NAME)] = export_names - - return {"return_code": return_code, "update_context": update_context} - -if __name__ == "__main__": - # data - data = {"name": "ABC"} - - # from previus task - event = {"data": data} - - main(event) - diff --git a/tests/tasks/win/get_from_scene.py b/tests/tasks/win/get_from_scene.py deleted file mode 100644 index 6e60728..0000000 --- a/tests/tasks/win/get_from_scene.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*-coding: utf8-*- -import os -from puzzle2.PzLog import PzLog - -PIECE_NAME = "get_from_scene" - -def main(event={}, context={}): - logger = context.get("logger") - if not logger: - logger = PzLog().logger - - update_context = {} - update_context["main"] = [{"name": "a", "category": "ch"}, - {"name": "b", "category": "ch"}, - {"name": "c", "category": "prop"}] - - logger.debug("add data: {}".format(update_context)) - - return {"return_code": 0, "update_context": update_context} - - -if __name__ == "__main__": - event = {"data": {"open_path": "A"}} - main(event) \ No newline at end of file diff --git a/tests/tasks/win/get_from_scene_empty.py b/tests/tasks/win/get_from_scene_empty.py deleted file mode 100644 index d5e870f..0000000 --- a/tests/tasks/win/get_from_scene_empty.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*-coding: utf8-*- -import os -from puzzle2.PzLog import PzLog - -PIECE_NAME = "get_from_scene" - -def main(event={}, context={}): - logger = context.get("logger") - if not logger: - logger = PzLog().logger - - update_context = {} - update_context["main"] = [] - - logger.debug("add data: {}".format(update_context)) - if len(update_context["main"]) == 0: - return {"return_code": 1, "update_context": update_context} - else: - return {"return_code": 0, "update_context": update_context} - - -if __name__ == "__main__": - event = {"data": {"open_path": "A"}} - print(main(event)) \ No newline at end of file diff --git a/tests/tasks/win/get_from_scene_empty_break_on_conditions.py b/tests/tasks/win/get_from_scene_empty_break_on_conditions.py deleted file mode 100644 index e801924..0000000 --- a/tests/tasks/win/get_from_scene_empty_break_on_conditions.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*-coding: utf8-*- -import os -from puzzle2.PzLog import PzLog - -PIECE_NAME = "get_from_scene_empty_break_on_conditions" - -def main(event={}, context={}): - logger = context.get("logger") - if not logger: - logger = PzLog().logger - data = event["data"] - update_context = {} - return {"return_code": data.get("return_code", 0), "update_context": update_context, "break_on_conditions": True} - - -if __name__ == "__main__": - event = {"data": {"open_path": "A"}} - print(main(event)) - - import doctest - doctest.testmod() \ No newline at end of file diff --git a/tests/tasks/win/goto_frame.py b/tests/tasks/win/goto_frame.py deleted file mode 100644 index 45bde64..0000000 --- a/tests/tasks/win/goto_frame.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/python -# -*- coding:utf-8 -*- -# ****** SETTINGS START (DO NOT TOUCH) ****** -# PRESET TYPE: PY -# PRESET ICON: goto_frame.png -# PRESET NAME: Clean Unknown Plugins -# PRESET COLOR: 0,168,120 -# DESCRIPTION: フレーム移動 -# BACKUP FILE: Yes -# SAVEAS FILE: No -# SAVE FILE: Yes -# OPEN AS: open -# ****** SETTINGS END (DO NOT TOUCH) ****** - -import os -from puzzle2.PzLog import PzLog - -PIECE_NAME = "GotoFrame" - -def main(event={}, context={}): - status = 1 - logger = context.get("logger", PzLog().logger) - logger.debug("test") - - return {"return_code": status} - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tests/tasks/win/import_file.py b/tests/tasks/win/import_file.py deleted file mode 100644 index 47e6588..0000000 --- a/tests/tasks/win/import_file.py +++ /dev/null @@ -1,28 +0,0 @@ -from puzzle2.PzLog import PzLog - -TASK_NAME = "import_file" -DATA_KEY_REQUIRED = ["path"] - -def main(event={}, context={}): - """ - import file from somewhare - - key required from data: - path: str - """ - - data = event.get("data", {}) - task = event.get("task", {}) - - logger = context.get("logger") - if not logger: - logger = PzLog().logger - - return_code = 0 - - return {"return_code": return_code} - - -if __name__ == "__main__": - data = {"path": "str"} - main(event={"data": data}) \ No newline at end of file diff --git a/tests/tasks/win/mock.py b/tests/tasks/win/mock.py deleted file mode 100644 index 2775eb7..0000000 --- a/tests/tasks/win/mock.py +++ /dev/null @@ -1,34 +0,0 @@ -from puzzle2.PzLog import PzLog - -TASK_NAME = "mock" - -DATA_KEY_REQUIRED = ["name"] - -def main(event={}, context={}): - """ - this is for testing - - key required from data: - name: something - """ - - data = event.get("data", {}) - task = event.get("task", {}) - update_context = {} - - logger = context.get("logger") - if not logger: - logger = PzLog().logger - - return_code = 0 - - logger.debug(data["name"]) - - update_context["{}.new_name".format(TASK_NAME)] = "new_name" - - return {"return_code": return_code, "update_context": update_context} - - -if __name__ == "__main__": - data = {"name": "something"} - main(event={"data": data}) \ No newline at end of file diff --git a/tests/tasks/win/open_file.py b/tests/tasks/win/open_file.py deleted file mode 100644 index 2dd61ed..0000000 --- a/tests/tasks/win/open_file.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/python -# -*- coding:utf-8 -*- -# ****** SETTINGS START (DO NOT TOUCH) ****** -# PRESET TYPE: PY -# PRESET ICON: open_file.png -# PRESET NAME: Clean Unknown Plugins -# PRESET COLOR: 0,168,120 -# DESCRIPTION: ファイルを開く -# BACKUP FILE: Yes -# SAVEAS FILE: No -# SAVE FILE: Yes -# OPEN AS: open -# ****** SETTINGS END (DO NOT TOUCH) ****** - -import os -from puzzle2.PzLog import PzLog - -TASK_NAME = "OpenFile" -DATA_KEY_REQUIRED = ["open_path"] - -def main(event={}, context={}): - data = event.get("data", {}) - logger = context.get("logger") - if not logger: - logger = PzLog().logger - - task = event.get("task", {}) - update_context = {} - - logger = context.get("logger") - if not logger: - logger = PzLog().logger - - header = "" - - if data["open_path"] is None: - logger.debug("open new") - header = u"file opened: new" - logger.details.add_detail("file opened: new") - # print("|RESULT| file opened: new") - - elif os.path.exists(data["open_path"]): - header = u"file opened: {}".format(data["open_path"]) - logger.debug(header) - logger.details.add_detail("file opened: {}".format(data["open_path"])) - # print("|RESULT| file opened: new") - - else: - header = u"file opened: new" - logger.debug(header) - logger.details.add_detail("file opened: new") - # print("|RESULT| file opened: new") - - logger.details.set_header(0, "open file successed") - logger.debug("done.") - - update_context["{}.update_context_test".format(TASK_NAME)] = TASK_NAME - return {"return_code": 0, "update_context": update_context} - -if __name__ == "__main__": - event = {"data": {"open_path": "A"}} - main(event) \ No newline at end of file diff --git a/tests/tasks/win/preview.py b/tests/tasks/win/preview.py deleted file mode 100644 index a134376..0000000 --- a/tests/tasks/win/preview.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*-coding: utf8-*- -import os -from puzzle2.PzLog import PzLog - -TASK_NAME = "preview" - -def main(event={}, context={}): - data = event["data"] - print("preview: {}".format(data["path"])) - return {"return_code": 0} - -if __name__ == "__main__": - event = {"data": {"add": 1, "path": "C:/"}} - main(event) diff --git a/tests/tasks/win/rename_namespace.py b/tests/tasks/win/rename_namespace.py deleted file mode 100644 index 18f649f..0000000 --- a/tests/tasks/win/rename_namespace.py +++ /dev/null @@ -1,35 +0,0 @@ -from puzzle2.PzLog import PzLog - -TASK_NAME = "rename_namespace" -DATA_KEY_REQUIRED = ["name"] - -def main(event={}, context={}): - """ - rename namespace from a to b - - key required from data: - name: str - """ - - data = event.get("data", {}) - task = event.get("task", {}) - update_context = {} - - logger = context.get("logger") - if not logger: - logger = PzLog().logger - - return_code = 0 - - new_name = "{}_01".format(data["name"]) - - logger.debug("new name: {}".format(new_name)) - - update_context["{}.new_name".format(TASK_NAME)] = new_name - - return {"return_code": return_code, "update_context": update_context} - - -if __name__ == "__main__": - data = {"name": "str"} - main(event={"data": data}) \ No newline at end of file diff --git a/tests/tasks/win/rename_namespace_with_error.py b/tests/tasks/win/rename_namespace_with_error.py deleted file mode 100644 index 925960b..0000000 --- a/tests/tasks/win/rename_namespace_with_error.py +++ /dev/null @@ -1,15 +0,0 @@ -from puzzle2.PzLog import PzLog - -TASK_NAME = "rename_namespace" -DATA_KEY_REQUIRED = ["name"] - -def main(event={}, context={}): - """ - this script will be error - """ - return {"return_code": return_code, "data_globals": data_globals} - - -if __name__ == "__main__": - data = {"name": "str"} - main(event={"data": data}) \ No newline at end of file diff --git a/tests/tasks/win/revert.py b/tests/tasks/win/revert.py deleted file mode 100644 index d81e029..0000000 --- a/tests/tasks/win/revert.py +++ /dev/null @@ -1,32 +0,0 @@ -from puzzle2.PzLog import PzLog - -TASK_NAME = "revert" -DATA_KEY_REQUIRED = ["revert"] - -def main(event={}, context={}): - """ - this task set to closere and revert things - - key required from data: - revert: dict - """ - - data = event.get("data", {}) - task = event.get("task", {}) - update_context = {} - - logger = context.get("logger") - if not logger: - logger = PzLog().logger - - return_code = 0 - logger.debug("revert run") - for k, v in data.get("revert", {}).items(): - logger.debug("{}: {}".format(k, v)) - - return {"return_code": return_code, "update_context": update_context} - - -if __name__ == "__main__": - data = {"revert": {"a": "b"}} - main(event={"data": data}) \ No newline at end of file diff --git a/tests/tasks/win/save_file.py b/tests/tasks/win/save_file.py deleted file mode 100644 index ad54e12..0000000 --- a/tests/tasks/win/save_file.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/python -# -*- coding:utf-8 -*- -# ****** SETTINGS START (DO NOT TOUCH) ****** -# PRESET TYPE: PY -# PRESET ICON: save_file.png -# PRESET NAME: Clean Unknown Plugins -# PRESET COLOR: 0,168,120 -# DESCRIPTION: 不要なプラグインの削除+シーン保存 -# BACKUP FILE: Yes -# SAVEAS FILE: No -# SAVE FILE: Yes -# OPEN AS: open -# ****** SETTINGS END (DO NOT TOUCH) ****** - -import os -from puzzle2.PzLog import PzLog - -TASK_NAME = "SaveFile" - -def main(event={}, context={}): - data = event.get("data", {}) - update_context = {} - logger = context.get("logger") - if not logger: - logger = PzLog().logger - - - header = u"file saved to: {}".format(data["path"]) - logger.debug(header) - logger.details.add_detail("file saved to: {}".format(data["path"])) - print("|RESULT| file saved to: {}".format(data)) - - update_context["{}.update_context_test".format(TASK_NAME)] = TASK_NAME - return {"return_code": 0, "update_context": update_context} - -if __name__ == "__main__": - event = {"data": {"path": "A"}} - - main(event) \ No newline at end of file diff --git a/tests/tasks/win/submit_to_sg.py b/tests/tasks/win/submit_to_sg.py deleted file mode 100644 index 55b8386..0000000 --- a/tests/tasks/win/submit_to_sg.py +++ /dev/null @@ -1,31 +0,0 @@ -from puzzle2.PzLog import PzLog - -TASK_NAME = "submit_to_sg" -DATA_KEY_REQUIRED = ["shot_code", "assets"] - -def main(event={}, context={}): - """ - submit to shotgrid mook - - key required from data: - shot_code: str - assets: list - """ - - data = event.get("data", {}) - task = event.get("task", {}) - - logger = context.get("logger") - if not logger: - logger = PzLog().logger - - return_code = 0 - - logger.debug("submit: {}".format(data["shot_code"], data["assets"])) - - return {"return_code": return_code} - - -if __name__ == "__main__": - data = {"shot_code": "ep000_s000_c000", "assets": []} - main(event={"data": data}) \ No newline at end of file From f406ea9f589340573972f45e0ec813dda81cf940 Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 18:05:34 +0900 Subject: [PATCH 02/14] move tests_data --- tests/tests_data/README_convert_test.md | 13 ++++ tests/tests_data/convert.yml | 64 +++++++++++++++++++ tests/tests_data/log/test.log.template.yml | 24 +++++++ tests/tests_data/tasks/__init__.py | 0 tests/tests_data/tasks/maya/create_cube.py | 23 +++++++ .../tests_data/tasks/maya/import_animation.py | 44 +++++++++++++ tests/tests_data/tasks/maya/import_file.py | 24 +++++++ tests/tests_data/tasks/maya/open_file.py | 26 ++++++++ tests/tests_data/tasks/maya/playblast.py | 15 +++++ tests/tests_data/tasks/maya/save_file.py | 13 ++++ tests/tests_data/tasks/mobu/export_file.py | 40 ++++++++++++ .../tasks/mobu/init_create_cubes.py | 49 ++++++++++++++ tests/tests_data/tasks/mobu/plot_all.py | 21 ++++++ tests/tests_data/tasks/win/__init__.py | 0 .../tasks/win/add_specified_data.py | 15 +++++ .../tasks/win/add_sys_path_test/__init__.py | 0 .../win/add_sys_path_test/other_location.py | 19 ++++++ .../tests_data/tasks/win/append_reference.py | 22 +++++++ tests/tests_data/tasks/win/bake_all.py | 33 ++++++++++ tests/tests_data/tasks/win/change_frame.py | 60 +++++++++++++++++ tests/tests_data/tasks/win/export_file.py | 46 +++++++++++++ tests/tests_data/tasks/win/get_from_scene.py | 24 +++++++ .../tasks/win/get_from_scene_empty.py | 24 +++++++ ...et_from_scene_empty_break_on_conditions.py | 21 ++++++ tests/tests_data/tasks/win/goto_frame.py | 28 ++++++++ tests/tests_data/tasks/win/import_file.py | 31 +++++++++ tests/tests_data/tasks/win/mock.py | 34 ++++++++++ tests/tests_data/tasks/win/open_file.py | 62 ++++++++++++++++++ tests/tests_data/tasks/win/preview.py | 14 ++++ .../tests_data/tasks/win/rename_namespace.py | 37 +++++++++++ .../tasks/win/rename_namespace_with_error.py | 15 +++++ tests/tests_data/tasks/win/revert.py | 32 ++++++++++ tests/tests_data/tasks/win/save_file.py | 39 +++++++++++ tests/tests_data/tasks/win/submit_to_sg.py | 31 +++++++++ tests/tests_data/tasks/win/update_shotgrid.py | 5 ++ tests/tests_data/tasks/win/varidate.py | 25 ++++++++ tests/tests_data/win/data.json | 10 +++ tests/tests_data/win/puzzle.bat | 13 ++++ tests/tests_data/win/puzzle2.bat | 2 + tests/tests_data/win/puzzle2_data.bat | 2 + tests/tests_data/win/puzzle2_data.job | 1 + tests/tests_data/win/puzzle2_data.json | 1 + tests/tests_data/win/puzzle2_result.bat | 0 tests/tests_data/win/task_set.yml | 11 ++++ 44 files changed, 1013 insertions(+) create mode 100644 tests/tests_data/README_convert_test.md create mode 100644 tests/tests_data/convert.yml create mode 100644 tests/tests_data/log/test.log.template.yml create mode 100644 tests/tests_data/tasks/__init__.py create mode 100644 tests/tests_data/tasks/maya/create_cube.py create mode 100644 tests/tests_data/tasks/maya/import_animation.py create mode 100644 tests/tests_data/tasks/maya/import_file.py create mode 100644 tests/tests_data/tasks/maya/open_file.py create mode 100644 tests/tests_data/tasks/maya/playblast.py create mode 100644 tests/tests_data/tasks/maya/save_file.py create mode 100644 tests/tests_data/tasks/mobu/export_file.py create mode 100644 tests/tests_data/tasks/mobu/init_create_cubes.py create mode 100644 tests/tests_data/tasks/mobu/plot_all.py create mode 100644 tests/tests_data/tasks/win/__init__.py create mode 100644 tests/tests_data/tasks/win/add_specified_data.py create mode 100644 tests/tests_data/tasks/win/add_sys_path_test/__init__.py create mode 100644 tests/tests_data/tasks/win/add_sys_path_test/other_location.py create mode 100644 tests/tests_data/tasks/win/append_reference.py create mode 100644 tests/tests_data/tasks/win/bake_all.py create mode 100644 tests/tests_data/tasks/win/change_frame.py create mode 100644 tests/tests_data/tasks/win/export_file.py create mode 100644 tests/tests_data/tasks/win/get_from_scene.py create mode 100644 tests/tests_data/tasks/win/get_from_scene_empty.py create mode 100644 tests/tests_data/tasks/win/get_from_scene_empty_break_on_conditions.py create mode 100644 tests/tests_data/tasks/win/goto_frame.py create mode 100644 tests/tests_data/tasks/win/import_file.py create mode 100644 tests/tests_data/tasks/win/mock.py create mode 100644 tests/tests_data/tasks/win/open_file.py create mode 100644 tests/tests_data/tasks/win/preview.py create mode 100644 tests/tests_data/tasks/win/rename_namespace.py create mode 100644 tests/tests_data/tasks/win/rename_namespace_with_error.py create mode 100644 tests/tests_data/tasks/win/revert.py create mode 100644 tests/tests_data/tasks/win/save_file.py create mode 100644 tests/tests_data/tasks/win/submit_to_sg.py create mode 100644 tests/tests_data/tasks/win/update_shotgrid.py create mode 100644 tests/tests_data/tasks/win/varidate.py create mode 100644 tests/tests_data/win/data.json create mode 100644 tests/tests_data/win/puzzle.bat create mode 100644 tests/tests_data/win/puzzle2.bat create mode 100644 tests/tests_data/win/puzzle2_data.bat create mode 100644 tests/tests_data/win/puzzle2_data.job create mode 100644 tests/tests_data/win/puzzle2_data.json create mode 100644 tests/tests_data/win/puzzle2_result.bat create mode 100644 tests/tests_data/win/task_set.yml diff --git a/tests/tests_data/README_convert_test.md b/tests/tests_data/README_convert_test.md new file mode 100644 index 0000000..f90650b --- /dev/null +++ b/tests/tests_data/README_convert_test.md @@ -0,0 +1,13 @@ +This folder contains test data and task definitions migrated from `sandbox/convert_test`. + +- `convert.yml`: pipeline definition adapted for tests. +- `tasks/`: DCC task modules (Maya/Mobu/Win). DCC APIs are imported lazily in `main()` so default pytest runs (without DCC) remain green. In such cases, these tasks typically return `{ "return_code": 4 }` to indicate skip/noop. +- Data files continue to live under `sandbox/convert_test/data` for now to avoid bloating the repository; paths are referenced relatively from the test tasks. + +Enable DCC tests via: + +PowerShell: + +``` +$env:PUZZLE_RUN_DCC_TESTS = "1"; pytest -q +``` diff --git a/tests/tests_data/convert.yml b/tests/tests_data/convert.yml new file mode 100644 index 0000000..6ed0b6c --- /dev/null +++ b/tests/tests_data/convert.yml @@ -0,0 +1,64 @@ +info: {} +data: + convert_test: + - step: init + tasks: + - name: mobu_create_cubes + module: tasks.mobu.init_create_cubes + + - step: pre + tasks: + - module: tasks.mobu.plot_all + + - step: main + tasks: + - module: tasks.mobu.export_file + data_key_replace: + export_path: export_fbx_path + + - step: pipe + pipe: + app: mayapy + version: 2024 + sys_path: "" + tasks: + - step: pre + tasks: + - module: tasks.maya.create_cube + data_defaults: + name: model + - step: main + tasks: + - module: tasks.maya.open_file + data_defaults: + new: true + + - name: import_animation + module: tasks.maya.import_animation + data_key_replace: + asset_path: context.create_cube.asset_path + fbx_path: export_fbx_path + save_path: asset_save_path + + - step: new + tasks: + - module: tasks.maya.open_file + data_defaults: + new: true + + - step: main + tasks: + - module: tasks.maya.import_file + data_key_replace: + import_path: asset_save_path + + - step: post + tasks: + - module: tasks.maya.save_file + - module: tasks.maya.playblast + + - step: post + tasks: + - module: tasks.win.update_shotgrid + data_key_replace: + movie_path: mov_path diff --git a/tests/tests_data/log/test.log.template.yml b/tests/tests_data/log/test.log.template.yml new file mode 100644 index 0000000..a7d92d6 --- /dev/null +++ b/tests/tests_data/log/test.log.template.yml @@ -0,0 +1,24 @@ +info: + name: log.template + +data: + version: 1 + disable_existing_loggers: false + formatters: + simple_formatter: + format: '%(asctime)-25s %(levelname)-10s %(module)-20s %(message)s' + datefmt: '%m-%d-%y %H:%M' + + handlers: + stream_handler: + class: logging.StreamHandler + level: DEBUG + formatter: simple_formatter + stream: ext://sys.stdout + + file_handler: + class: logging.FileHandler + level: DEBUG + formatter: simple_formatter + filename: logfile.log + mode: a \ No newline at end of file diff --git a/tests/tests_data/tasks/__init__.py b/tests/tests_data/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests_data/tasks/maya/create_cube.py b/tests/tests_data/tasks/maya/create_cube.py new file mode 100644 index 0000000..ce68cd1 --- /dev/null +++ b/tests/tests_data/tasks/maya/create_cube.py @@ -0,0 +1,23 @@ +# -*-coding: utf8-*- +"""Create a cube and save asset path into context (Maya). +Lazy-import Maya modules so tests without Maya don't crash. +""" +import os + +DATA_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "sandbox", "convert_test")).replace("\\", "/") + + +def main(event={}, context={}): + try: + import maya.cmds as cmds # lazy import + except Exception: + return {"return_code": 4} + + asset_path = f"{DATA_ROOT}/data/asset_cube.ma" + if not os.path.exists(asset_path): + cmds.polyCube(name="model") + cmds.file(rename=asset_path) + cmds.file(save=True, type="mayaAscii") + + update_context = {"create_cube.asset_path": asset_path} + return {"return_code": 0, "update_context": update_context} diff --git a/tests/tests_data/tasks/maya/import_animation.py b/tests/tests_data/tasks/maya/import_animation.py new file mode 100644 index 0000000..640c0c1 --- /dev/null +++ b/tests/tests_data/tasks/maya/import_animation.py @@ -0,0 +1,44 @@ +# -*-coding: utf8-*- +"""Import FBX animation and save Maya scene. +""" + +def main(event={}, context={}): + data = event.get("data", {}) + try: + import maya.cmds as cmds + except Exception: + return {"return_code": 4} + + # Ensure FBX plugin + if not cmds.pluginInfo("fbxmaya", q=True, l=True): + try: + cmds.loadPlugin("fbxmaya") + except Exception: + pass + + # Reference asset then import FBX if provided + if data.get("asset_path"): + cmds.file( + data["asset_path"], r=True, gl=False, lrd="all", iv=True, force=True, namespace=data.get("namespace", "ns") + ) + + if (path := data.get("fbx_path")) and path.lower().endswith(".fbx"): + cmds.file( + path, + i=True, + type="FBX", + ignoreVersion=True, + ra=True, + mergeNamespacesOnClash=False, + options="v=0;", + pr=True, + ) + + if save := data.get("save_path"): + import os + + os.makedirs(os.path.dirname(save), exist_ok=True) + cmds.file(rename=save) + cmds.file(save=True, type="mayaAscii") + + return {"return_code": 0} diff --git a/tests/tests_data/tasks/maya/import_file.py b/tests/tests_data/tasks/maya/import_file.py new file mode 100644 index 0000000..9bc8666 --- /dev/null +++ b/tests/tests_data/tasks/maya/import_file.py @@ -0,0 +1,24 @@ +# -*-coding: utf8-*- +import os + +def main(event={}, context={}): + try: + import maya.cmds as cmds + except Exception: + return {"return_code": 4} + + data = event.get("data", {}) + if path := data.get("import_path"): + os.makedirs(os.path.dirname(path), exist_ok=True) + cmds.file( + path, + i=True, + type="mayaAscii", + ignoreVersion=True, + ra=True, + mergeNamespacesOnClash=False, + namespace=data.get("namespace", "ns"), + options="v=0;p=17;f=0", + pr=True, + ) + return {"return_code": 0} diff --git a/tests/tests_data/tasks/maya/open_file.py b/tests/tests_data/tasks/maya/open_file.py new file mode 100644 index 0000000..08b3631 --- /dev/null +++ b/tests/tests_data/tasks/maya/open_file.py @@ -0,0 +1,26 @@ +# -*-coding: utf8-*- +""" +Maya task: open a new scene or open an existing path. +This module is imported by Maya runtime; keep imports lazy to avoid ImportError during normal pytest runs. +""" + +import os + +DATA_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "sandbox", "convert_test")).replace("\\", "/") + + +def main(event={}, context={}): + data = event.get("data", {}) + try: + import maya.cmds as cmds # lazy import + except Exception: + # Not in Maya; indicate skip/noop + return {"return_code": 4} + + if data.get("new"): + cmds.file(new=True, force=True) + else: + path = data.get("open_path") or os.path.join(DATA_ROOT, "data", "asset_cube.ma") + cmds.file(path, open=True, force=True) + + return {"return_code": 0} diff --git a/tests/tests_data/tasks/maya/playblast.py b/tests/tests_data/tasks/maya/playblast.py new file mode 100644 index 0000000..8d0bc69 --- /dev/null +++ b/tests/tests_data/tasks/maya/playblast.py @@ -0,0 +1,15 @@ +# -*-coding: utf8-*- +import os + +def main(event={}, context={}): + try: + import maya.cmds as cmds + except Exception: + return {"return_code": 4} + + data = event.get("data", {}) + mov_path = data.get("mov_path") + if mov_path: + os.makedirs(os.path.dirname(mov_path), exist_ok=True) + open(mov_path, "w").close() + return {"return_code": 0} diff --git a/tests/tests_data/tasks/maya/save_file.py b/tests/tests_data/tasks/maya/save_file.py new file mode 100644 index 0000000..a8c8e81 --- /dev/null +++ b/tests/tests_data/tasks/maya/save_file.py @@ -0,0 +1,13 @@ +# -*-coding: utf8-*- + +def main(event={}, context={}): + try: + import maya.cmds as cmds + except Exception: + return {"return_code": 4} + + data = event.get("data", {}) + if save := data.get("save_path"): + cmds.file(rename=save) + cmds.file(save=True, type="mayaAscii") + return {"return_code": 0} diff --git a/tests/tests_data/tasks/mobu/export_file.py b/tests/tests_data/tasks/mobu/export_file.py new file mode 100644 index 0000000..0a34870 --- /dev/null +++ b/tests/tests_data/tasks/mobu/export_file.py @@ -0,0 +1,40 @@ +# -*-coding: utf8-*- +"""MotionBuilder: export selected to FBX. +Lazy import pyfbsdk to avoid import error outside of Mobu. +""" +import os + +def main(event={}, context={}): + try: + from pyfbsdk import ( + FBApplication, + FBComponentList, + FBFindObjectsByName, + FBModelList, + FBFbxOptions, + ) + except Exception: + return {"return_code": 4} + + data = event.get("data", {}) + # Deselect all + model_list = FBModelList() + # FBGetSelectedModels(model_list) # Selecting via name below + component = FBComponentList() + model_name = f"{data.get('namespace') + ':' if data.get('namespace') else ''}{data.get('name')}" + FBFindObjectsByName(str(model_name), component, True, True) + for model in component: + model.Selected = True + + save_option = FBFbxOptions(False) + save_option.SetAll(save_option.kFBElementActionSave, True) + save_option.EmbedMedia = False + save_option.SaveSelectedModelsOnly = True + save_option.ShowFileDialog = False + save_option.ShowOptionsDialog = False + + path = str(data.get("export_path")) + if path: + os.makedirs(os.path.dirname(path), exist_ok=True) + FBApplication().FileSave(path, save_option) + return {"return_code": 0} diff --git a/tests/tests_data/tasks/mobu/init_create_cubes.py b/tests/tests_data/tasks/mobu/init_create_cubes.py new file mode 100644 index 0000000..3c7620b --- /dev/null +++ b/tests/tests_data/tasks/mobu/init_create_cubes.py @@ -0,0 +1,49 @@ +# -*-coding: utf8-*- +"""MotionBuilder: create sample cubes and update context with per-item paths.""" + +import os +import random + +def main(event={}, context={}): + try: + from pyfbsdk import FBModelCube, FBSystem, FBPlayerControl, FBTime + except Exception: + return {"return_code": 4} + + data_root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "sandbox", "convert_test", "data")).replace("\\", "/") + + def add_trs(model, xyz, frame): + t = FBTime(0, 0, 0, frame) + model.Translation.SetAnimated(True) + for i, v in enumerate(xyz): + if v is not None: + model.Translation.GetAnimationNode().Nodes[i].FCurve.KeyAdd(t, v) + + # Build synthetic scene + model_name = "model" + models = [] + main = [] + for i in range(3): + namespace = f"Cube{i:02d}" + cube = FBModelCube(f"{namespace}:{model_name}") + cube.Show = True + for j in range(3): + add_trs(cube, [random.randint(0, 10) for _ in range(3)], j * 10) + models.append(cube.Name) + main.append({ + "namespace": namespace, + "name": model_name, + "export_fbx_path": f"{data_root}/{namespace}_export.fbx", + "asset_save_path": f"{data_root}/{namespace}_import.ma", + }) + + post = { + "save_path": f"{data_root}/result_file.ma", + "mov_path": f"{data_root}/result_file.mov", + } + + FBPlayerControl().LoopStop = FBTime(0, 0, 0, 30) + FBSystem().Scene.Evaluate() + + update_context = {"main": main, "pre": {"models": models}, "post": post} + return {"update_context": update_context, "return_code": 0} diff --git a/tests/tests_data/tasks/mobu/plot_all.py b/tests/tests_data/tasks/mobu/plot_all.py new file mode 100644 index 0000000..f9e4ad1 --- /dev/null +++ b/tests/tests_data/tasks/mobu/plot_all.py @@ -0,0 +1,21 @@ +# -*-coding: utf8-*- +"""MotionBuilder: plot selected models animation.""" + +def main(event={}, context={}): + try: + from pyfbsdk import FBSystem, FBPlotOptions, FBTime + except Exception: + return {"return_code": 4} + + data = event.get("data", {}) + # Configure plot options + plot_option = FBPlotOptions() + plot_option.PlotOnFlame = True + plot_option.ConstantKeyReducerKeepOneKey = True + plot_option.UseConstantKeyReducer = False + plot_option.RotationFilterToApply = FBPlotOptions.kFBRotationFilterUnroll if hasattr(FBPlotOptions, 'kFBRotationFilterUnroll') else 0 + plot_option.PlotPeriod = FBTime(0, 0, 0, 1) + + # Plot current take on selected + FBSystem().CurrentTake.PlotTakeOnSelected(plot_option) + return {"return_code": 0} diff --git a/tests/tests_data/tasks/win/__init__.py b/tests/tests_data/tasks/win/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests_data/tasks/win/add_specified_data.py b/tests/tests_data/tasks/win/add_specified_data.py new file mode 100644 index 0000000..13bd121 --- /dev/null +++ b/tests/tests_data/tasks/win/add_specified_data.py @@ -0,0 +1,15 @@ +# -*-coding: utf8-*- +import os +from puzzle2.PzLog import PzLog + +PIECE_NAME = "add_specified_data" + +def main(event={}, context={}): + data = event["data"] + update_context = {} + update_context["{}.add".format(PIECE_NAME)] = data["add"] + return {"update_context": update_context} + +if __name__ == "__main__": + event = {"data": {"add": 1}} + main(event) diff --git a/tests/tests_data/tasks/win/add_sys_path_test/__init__.py b/tests/tests_data/tasks/win/add_sys_path_test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests_data/tasks/win/add_sys_path_test/other_location.py b/tests/tests_data/tasks/win/add_sys_path_test/other_location.py new file mode 100644 index 0000000..cfc2f38 --- /dev/null +++ b/tests/tests_data/tasks/win/add_sys_path_test/other_location.py @@ -0,0 +1,19 @@ +from puzzle2.PzLog import PzLog + +TASK_NAME = "other_location" + +def main(event={}, context={}): + data = event.get("data", {}) + + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + return_code = 0 + + return {"return_code": return_code} + + +if __name__ == "__main__": + data = {"": ""} + main(event={"data": data}) \ No newline at end of file diff --git a/tests/tests_data/tasks/win/append_reference.py b/tests/tests_data/tasks/win/append_reference.py new file mode 100644 index 0000000..eaa7256 --- /dev/null +++ b/tests/tests_data/tasks/win/append_reference.py @@ -0,0 +1,22 @@ +# -*-coding: utf8-*- +import os +import copy +from puzzle2.PzLog import PzLog + +TASK_NAME = "append_reference" + +def main(event={}, context={}): + data = event["data"] + print("append reference: {}".format(data["name"])) + key = "{}.update_context_test".format(TASK_NAME) + update_context = {} + if key in context: + update_context[key] = copy.deepcopy(context[key]) + update_context[key].append(data["name"]) + else: + update_context = {key: [data["name"]]} + return {"return_code": 0, "update_context": update_context} + +if __name__ == "__main__": + event = {"data": {"add": 1}} + main(event) diff --git a/tests/tests_data/tasks/win/bake_all.py b/tests/tests_data/tasks/win/bake_all.py new file mode 100644 index 0000000..4553e1b --- /dev/null +++ b/tests/tests_data/tasks/win/bake_all.py @@ -0,0 +1,33 @@ +from puzzle2.PzLog import PzLog + +TASK_NAME = "bake_all" +DATA_KEY_REQUIRED = ["assets"] + +def main(event={}, context={}): + """ + bake all assets + + key required from data: + assets: list + """ + + data = event.get("data", {}) + task = event.get("task", {}) + data_globals = event.get("data_globals", {}) + + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + return_code = 0 + + + import time + # time.sleep(1.2) + + return {"return_code": return_code, "data_globals": data_globals} + + +if __name__ == "__main__": + data = {"assets": [{"name": "a"}, {"name": "b"}]} + main(event={"data": data}) \ No newline at end of file diff --git a/tests/tests_data/tasks/win/change_frame.py b/tests/tests_data/tasks/win/change_frame.py new file mode 100644 index 0000000..c99134a --- /dev/null +++ b/tests/tests_data/tasks/win/change_frame.py @@ -0,0 +1,60 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +# ****** SETTINGS START (DO NOT TOUCH) ****** +# PRESET TYPE: PY +# PRESET ICON: change_frame.png +# PRESET NAME: Clean Unknown Plugins +# PRESET COLOR: 0,168,120 +# DESCRIPTION: フレーム変更 +# BACKUP FILE: Yes +# SAVEAS FILE: No +# SAVE FILE: Yes +# OPEN AS: open +# ****** SETTINGS END (DO NOT TOUCH) ****** + +import time + +TASK_NAME = "ChangeFrame" +from puzzle2.PzLog import PzLog + +def main(event={}, context={}): + data = event.get("data", {}) + task = event.get("task", {}) + update_context = {} + logger = context.get("logger") + if not logger: + logger = PzLog().logger + return_code = 0 + + logger.info("frame is: {}".format(data["frame"])) + + frame = data["frame"] + 100 + update_context["frame"] = frame + update_context["XXXXX"] = "abcde" + + logger.info("set frame to: {}(info)".format(frame)) + logger.debug("set frame to: {}(debug)".format(frame)) + + logger.details.add_detail("test details") + logger.details.set_header(0, "change frame to: {}".format(frame)) + + # logger.error("ERROR occured!") + # logger.warning("Oops, something is wrong...") + # logger.success(ui, "FINISHED!") + # logger.updateUI(ui, "Updated!", level="RESULT") + + update_context["{}.data_globals_test".format(TASK_NAME)] = TASK_NAME + return {"return_code": return_code, "update_context": update_context} + +if __name__ == "__main__": + # from config file + task = {"name": "hoge", "a": 2, "paint": {"frame": "@frame"}} + + # data + data = {"frame": 156789} + + # from previus task + + event = {"task": task, "data": data} + + main(event) diff --git a/tests/tests_data/tasks/win/export_file.py b/tests/tests_data/tasks/win/export_file.py new file mode 100644 index 0000000..28a9394 --- /dev/null +++ b/tests/tests_data/tasks/win/export_file.py @@ -0,0 +1,46 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +# ****** SETTINGS START (DO NOT TOUCH) ****** +# PRESET TYPE: PY +# PRESET ICON: change_frame.png +# PRESET NAME: Clean Unknown Plugins +# PRESET COLOR: 0,168,120 +# DESCRIPTION: フレーム変更 +# BACKUP FILE: Yes +# SAVEAS FILE: No +# SAVE FILE: Yes +# OPEN AS: open +# ****** SETTINGS END (DO NOT TOUCH) ****** + +import time + +TASK_NAME = "export_file" +from puzzle2.PzLog import PzLog + +def main(event={}, context={}): + data = event.get("data", {}) + update_context = {} + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + return_code = 0 + logger.debug("export: {}".format(data["name"])) + logger.details.add_detail("test details") + + # append value to previous task's value. + export_names = context.get("{}.export_names".format(TASK_NAME), []) + export_names.append(data["name"]) + update_context["{}.export_names".format(TASK_NAME)] = export_names + + return {"return_code": return_code, "update_context": update_context} + +if __name__ == "__main__": + # data + data = {"name": "ABC"} + + # from previus task + event = {"data": data} + + main(event) + diff --git a/tests/tests_data/tasks/win/get_from_scene.py b/tests/tests_data/tasks/win/get_from_scene.py new file mode 100644 index 0000000..6e60728 --- /dev/null +++ b/tests/tests_data/tasks/win/get_from_scene.py @@ -0,0 +1,24 @@ +# -*-coding: utf8-*- +import os +from puzzle2.PzLog import PzLog + +PIECE_NAME = "get_from_scene" + +def main(event={}, context={}): + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + update_context = {} + update_context["main"] = [{"name": "a", "category": "ch"}, + {"name": "b", "category": "ch"}, + {"name": "c", "category": "prop"}] + + logger.debug("add data: {}".format(update_context)) + + return {"return_code": 0, "update_context": update_context} + + +if __name__ == "__main__": + event = {"data": {"open_path": "A"}} + main(event) \ No newline at end of file diff --git a/tests/tests_data/tasks/win/get_from_scene_empty.py b/tests/tests_data/tasks/win/get_from_scene_empty.py new file mode 100644 index 0000000..d5e870f --- /dev/null +++ b/tests/tests_data/tasks/win/get_from_scene_empty.py @@ -0,0 +1,24 @@ +# -*-coding: utf8-*- +import os +from puzzle2.PzLog import PzLog + +PIECE_NAME = "get_from_scene" + +def main(event={}, context={}): + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + update_context = {} + update_context["main"] = [] + + logger.debug("add data: {}".format(update_context)) + if len(update_context["main"]) == 0: + return {"return_code": 1, "update_context": update_context} + else: + return {"return_code": 0, "update_context": update_context} + + +if __name__ == "__main__": + event = {"data": {"open_path": "A"}} + print(main(event)) \ No newline at end of file diff --git a/tests/tests_data/tasks/win/get_from_scene_empty_break_on_conditions.py b/tests/tests_data/tasks/win/get_from_scene_empty_break_on_conditions.py new file mode 100644 index 0000000..e801924 --- /dev/null +++ b/tests/tests_data/tasks/win/get_from_scene_empty_break_on_conditions.py @@ -0,0 +1,21 @@ +# -*-coding: utf8-*- +import os +from puzzle2.PzLog import PzLog + +PIECE_NAME = "get_from_scene_empty_break_on_conditions" + +def main(event={}, context={}): + logger = context.get("logger") + if not logger: + logger = PzLog().logger + data = event["data"] + update_context = {} + return {"return_code": data.get("return_code", 0), "update_context": update_context, "break_on_conditions": True} + + +if __name__ == "__main__": + event = {"data": {"open_path": "A"}} + print(main(event)) + + import doctest + doctest.testmod() \ No newline at end of file diff --git a/tests/tests_data/tasks/win/goto_frame.py b/tests/tests_data/tasks/win/goto_frame.py new file mode 100644 index 0000000..45bde64 --- /dev/null +++ b/tests/tests_data/tasks/win/goto_frame.py @@ -0,0 +1,28 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +# ****** SETTINGS START (DO NOT TOUCH) ****** +# PRESET TYPE: PY +# PRESET ICON: goto_frame.png +# PRESET NAME: Clean Unknown Plugins +# PRESET COLOR: 0,168,120 +# DESCRIPTION: フレーム移動 +# BACKUP FILE: Yes +# SAVEAS FILE: No +# SAVE FILE: Yes +# OPEN AS: open +# ****** SETTINGS END (DO NOT TOUCH) ****** + +import os +from puzzle2.PzLog import PzLog + +PIECE_NAME = "GotoFrame" + +def main(event={}, context={}): + status = 1 + logger = context.get("logger", PzLog().logger) + logger.debug("test") + + return {"return_code": status} + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/tests_data/tasks/win/import_file.py b/tests/tests_data/tasks/win/import_file.py new file mode 100644 index 0000000..ebdff22 --- /dev/null +++ b/tests/tests_data/tasks/win/import_file.py @@ -0,0 +1,31 @@ +from puzzle2.PzLog import PzLog + +TASK_NAME = "import_file" +DATA_KEY_REQUIRED = ["path"] + +def main(event={}, context={}): + """ + import file from somewhare + + key required from data: + path: str + """ + + data = event.get("data", {}) + task = event.get("task", {}) + + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + return_code = 0 + logger.details.set_header(return_code, "append anim file") + + import time + time.sleep(0.5) + return {"return_code": return_code} + + +if __name__ == "__main__": + data = {"path": "str"} + main(event={"data": data}) \ No newline at end of file diff --git a/tests/tests_data/tasks/win/mock.py b/tests/tests_data/tasks/win/mock.py new file mode 100644 index 0000000..2775eb7 --- /dev/null +++ b/tests/tests_data/tasks/win/mock.py @@ -0,0 +1,34 @@ +from puzzle2.PzLog import PzLog + +TASK_NAME = "mock" + +DATA_KEY_REQUIRED = ["name"] + +def main(event={}, context={}): + """ + this is for testing + + key required from data: + name: something + """ + + data = event.get("data", {}) + task = event.get("task", {}) + update_context = {} + + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + return_code = 0 + + logger.debug(data["name"]) + + update_context["{}.new_name".format(TASK_NAME)] = "new_name" + + return {"return_code": return_code, "update_context": update_context} + + +if __name__ == "__main__": + data = {"name": "something"} + main(event={"data": data}) \ No newline at end of file diff --git a/tests/tests_data/tasks/win/open_file.py b/tests/tests_data/tasks/win/open_file.py new file mode 100644 index 0000000..2dd61ed --- /dev/null +++ b/tests/tests_data/tasks/win/open_file.py @@ -0,0 +1,62 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +# ****** SETTINGS START (DO NOT TOUCH) ****** +# PRESET TYPE: PY +# PRESET ICON: open_file.png +# PRESET NAME: Clean Unknown Plugins +# PRESET COLOR: 0,168,120 +# DESCRIPTION: ファイルを開く +# BACKUP FILE: Yes +# SAVEAS FILE: No +# SAVE FILE: Yes +# OPEN AS: open +# ****** SETTINGS END (DO NOT TOUCH) ****** + +import os +from puzzle2.PzLog import PzLog + +TASK_NAME = "OpenFile" +DATA_KEY_REQUIRED = ["open_path"] + +def main(event={}, context={}): + data = event.get("data", {}) + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + task = event.get("task", {}) + update_context = {} + + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + header = "" + + if data["open_path"] is None: + logger.debug("open new") + header = u"file opened: new" + logger.details.add_detail("file opened: new") + # print("|RESULT| file opened: new") + + elif os.path.exists(data["open_path"]): + header = u"file opened: {}".format(data["open_path"]) + logger.debug(header) + logger.details.add_detail("file opened: {}".format(data["open_path"])) + # print("|RESULT| file opened: new") + + else: + header = u"file opened: new" + logger.debug(header) + logger.details.add_detail("file opened: new") + # print("|RESULT| file opened: new") + + logger.details.set_header(0, "open file successed") + logger.debug("done.") + + update_context["{}.update_context_test".format(TASK_NAME)] = TASK_NAME + return {"return_code": 0, "update_context": update_context} + +if __name__ == "__main__": + event = {"data": {"open_path": "A"}} + main(event) \ No newline at end of file diff --git a/tests/tests_data/tasks/win/preview.py b/tests/tests_data/tasks/win/preview.py new file mode 100644 index 0000000..a134376 --- /dev/null +++ b/tests/tests_data/tasks/win/preview.py @@ -0,0 +1,14 @@ +# -*-coding: utf8-*- +import os +from puzzle2.PzLog import PzLog + +TASK_NAME = "preview" + +def main(event={}, context={}): + data = event["data"] + print("preview: {}".format(data["path"])) + return {"return_code": 0} + +if __name__ == "__main__": + event = {"data": {"add": 1, "path": "C:/"}} + main(event) diff --git a/tests/tests_data/tasks/win/rename_namespace.py b/tests/tests_data/tasks/win/rename_namespace.py new file mode 100644 index 0000000..6d81cf0 --- /dev/null +++ b/tests/tests_data/tasks/win/rename_namespace.py @@ -0,0 +1,37 @@ +from puzzle2.PzLog import PzLog + +TASK_NAME = "rename_namespace" +DATA_KEY_REQUIRED = ["name"] + +def main(event={}, context={}): + """ + rename namespace from a to b + + key required from data: + name: str + """ + + data = event.get("data", {}) + task = event.get("task", {}) + update_context = {} + + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + return_code = 0 + + new_name = "{}_01".format(data["name"]) + + logger.debug("new name: {}".format(new_name)) + + update_context["{}.new_name".format(TASK_NAME)] = new_name + import time + # time.sleep(0.7) + logger.details.add_detail("test!") + return {"return_code": return_code, "update_context": update_context} + + +if __name__ == "__main__": + data = {"name": "str"} + main(event={"data": data}) \ No newline at end of file diff --git a/tests/tests_data/tasks/win/rename_namespace_with_error.py b/tests/tests_data/tasks/win/rename_namespace_with_error.py new file mode 100644 index 0000000..925960b --- /dev/null +++ b/tests/tests_data/tasks/win/rename_namespace_with_error.py @@ -0,0 +1,15 @@ +from puzzle2.PzLog import PzLog + +TASK_NAME = "rename_namespace" +DATA_KEY_REQUIRED = ["name"] + +def main(event={}, context={}): + """ + this script will be error + """ + return {"return_code": return_code, "data_globals": data_globals} + + +if __name__ == "__main__": + data = {"name": "str"} + main(event={"data": data}) \ No newline at end of file diff --git a/tests/tests_data/tasks/win/revert.py b/tests/tests_data/tasks/win/revert.py new file mode 100644 index 0000000..d81e029 --- /dev/null +++ b/tests/tests_data/tasks/win/revert.py @@ -0,0 +1,32 @@ +from puzzle2.PzLog import PzLog + +TASK_NAME = "revert" +DATA_KEY_REQUIRED = ["revert"] + +def main(event={}, context={}): + """ + this task set to closere and revert things + + key required from data: + revert: dict + """ + + data = event.get("data", {}) + task = event.get("task", {}) + update_context = {} + + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + return_code = 0 + logger.debug("revert run") + for k, v in data.get("revert", {}).items(): + logger.debug("{}: {}".format(k, v)) + + return {"return_code": return_code, "update_context": update_context} + + +if __name__ == "__main__": + data = {"revert": {"a": "b"}} + main(event={"data": data}) \ No newline at end of file diff --git a/tests/tests_data/tasks/win/save_file.py b/tests/tests_data/tasks/win/save_file.py new file mode 100644 index 0000000..ad54e12 --- /dev/null +++ b/tests/tests_data/tasks/win/save_file.py @@ -0,0 +1,39 @@ +#!/usr/bin/python +# -*- coding:utf-8 -*- +# ****** SETTINGS START (DO NOT TOUCH) ****** +# PRESET TYPE: PY +# PRESET ICON: save_file.png +# PRESET NAME: Clean Unknown Plugins +# PRESET COLOR: 0,168,120 +# DESCRIPTION: 不要なプラグインの削除+シーン保存 +# BACKUP FILE: Yes +# SAVEAS FILE: No +# SAVE FILE: Yes +# OPEN AS: open +# ****** SETTINGS END (DO NOT TOUCH) ****** + +import os +from puzzle2.PzLog import PzLog + +TASK_NAME = "SaveFile" + +def main(event={}, context={}): + data = event.get("data", {}) + update_context = {} + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + + header = u"file saved to: {}".format(data["path"]) + logger.debug(header) + logger.details.add_detail("file saved to: {}".format(data["path"])) + print("|RESULT| file saved to: {}".format(data)) + + update_context["{}.update_context_test".format(TASK_NAME)] = TASK_NAME + return {"return_code": 0, "update_context": update_context} + +if __name__ == "__main__": + event = {"data": {"path": "A"}} + + main(event) \ No newline at end of file diff --git a/tests/tests_data/tasks/win/submit_to_sg.py b/tests/tests_data/tasks/win/submit_to_sg.py new file mode 100644 index 0000000..55b8386 --- /dev/null +++ b/tests/tests_data/tasks/win/submit_to_sg.py @@ -0,0 +1,31 @@ +from puzzle2.PzLog import PzLog + +TASK_NAME = "submit_to_sg" +DATA_KEY_REQUIRED = ["shot_code", "assets"] + +def main(event={}, context={}): + """ + submit to shotgrid mook + + key required from data: + shot_code: str + assets: list + """ + + data = event.get("data", {}) + task = event.get("task", {}) + + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + return_code = 0 + + logger.debug("submit: {}".format(data["shot_code"], data["assets"])) + + return {"return_code": return_code} + + +if __name__ == "__main__": + data = {"shot_code": "ep000_s000_c000", "assets": []} + main(event={"data": data}) \ No newline at end of file diff --git a/tests/tests_data/tasks/win/update_shotgrid.py b/tests/tests_data/tasks/win/update_shotgrid.py new file mode 100644 index 0000000..53e8b17 --- /dev/null +++ b/tests/tests_data/tasks/win/update_shotgrid.py @@ -0,0 +1,5 @@ +# -*-coding: utf8-*- + +def main(event={}, context={}): + print("update shotgrid!") + return {"return_code": 0} diff --git a/tests/tests_data/tasks/win/varidate.py b/tests/tests_data/tasks/win/varidate.py new file mode 100644 index 0000000..813c8e2 --- /dev/null +++ b/tests/tests_data/tasks/win/varidate.py @@ -0,0 +1,25 @@ +from puzzle2.PzLog import PzLog + +TASK_NAME = "varidate" + +def main(event={}, context={}): + """ + + """ + + data = event.get("data", {}) + + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + if data["test"] == "fail": + logger.error("Test failed") + return {"return_code": 1} + else: + logger.info("Test passed") + return {"return_code": 0} + +if __name__ == "__main__": + data = {"": ""} + main(event={"data": data}) \ No newline at end of file diff --git a/tests/tests_data/win/data.json b/tests/tests_data/win/data.json new file mode 100644 index 0000000..ceb1753 --- /dev/null +++ b/tests/tests_data/win/data.json @@ -0,0 +1,10 @@ +{ + "info": {}, + "data": {"pre": + { + "open_path": "file path" + }, + "main": + [{"name": "nameA"}, {"name": "nameB"}] + } +} \ No newline at end of file diff --git a/tests/tests_data/win/puzzle.bat b/tests/tests_data/win/puzzle.bat new file mode 100644 index 0000000..fcbc41d --- /dev/null +++ b/tests/tests_data/win/puzzle.bat @@ -0,0 +1,13 @@ +SET PUZZLE_REPO_PATH=F:\works\colorrd\repogitories\puzzle\src +SET PUZZLE_DATA_PATH=H:/projects/colorrd/repos/puzzle2/tests/tests_data/win/data.json +SET PUZZLE_TASK_SET_PATH=H:/projects/colorrd/repos/puzzle2/tests/tests_data/win/task_set.yml +SET PUZZLE_LOGGER_DIRECTORY=C:/Users/ame_k/AppData/Local/Temp/puzzle/log +SET PUZZLE_LOGGER_NAME=puzzle +SET PUZZLE_TASK_KEYS= +SET PUZZLE_APP=mobupy +SET PUZZLE_SYS_PATH=H:/projects/tacg/repos/tama/src/python/py2/site-packages;h:/projects/colorrd/repos/puzzle2/src +SET PUZZLE_MODULE_DIRECTORY=h:\projects\colorrd\repos\puzzle2\tests +SET PUZZLE_CLOSE_APP=True +SET PUZZLE_RESULT_PATH=C:/Users/ame_k/AppData/Local/Temp/puzzle/test/test/test_batch_result_mobupy2020.json +"C:\Program Files\Autodesk\MotionBuilder 2020\bin\x64\mobupy.exe" "h:\projects\colorrd\repos\puzzle2\src\puzzle2\batch_kicker.py" +pause \ No newline at end of file diff --git a/tests/tests_data/win/puzzle2.bat b/tests/tests_data/win/puzzle2.bat new file mode 100644 index 0000000..c624527 --- /dev/null +++ b/tests/tests_data/win/puzzle2.bat @@ -0,0 +1,2 @@ +SET PUZZLE_JOB_PATH=C:/Users/Hattori/AppData/Local/Temp/puzzle/jobs/20250817072814/config.json +C:/Program Files/Autodesk/Maya2024/bin/maya.exe -batch -command "python(\"import sys;import os;sys.path.append(\\\"H:/projects/colorrd/repos/puzzle2/src\\\");import puzzle2.batch_kicker as batch_kicker;batch_kicker.main(\\\"C:/Users/Hattori/AppData/Local/Temp/puzzle/jobs/20250817072814/config.json\\\");"); \ No newline at end of file diff --git a/tests/tests_data/win/puzzle2_data.bat b/tests/tests_data/win/puzzle2_data.bat new file mode 100644 index 0000000..f6cac0d --- /dev/null +++ b/tests/tests_data/win/puzzle2_data.bat @@ -0,0 +1,2 @@ +SET PUZZLE_JOB_PATH=H:\projects\colorrd\repos\puzzle2\tests\jobs/20221218194708/config.json +"C:/Program Files/Autodesk/Maya2020/bin/mayapy.exe" "H:\projects\colorrd\repos\puzzle2\src\puzzle2\batch_kicker.py" \ No newline at end of file diff --git a/tests/tests_data/win/puzzle2_data.job b/tests/tests_data/win/puzzle2_data.job new file mode 100644 index 0000000..6192c1c --- /dev/null +++ b/tests/tests_data/win/puzzle2_data.job @@ -0,0 +1 @@ +{"info": {}, "data": {"init": {"open_path": "C:\\Users\\ame_k\\last.txt"}}} \ No newline at end of file diff --git a/tests/tests_data/win/puzzle2_data.json b/tests/tests_data/win/puzzle2_data.json new file mode 100644 index 0000000..2490c9d --- /dev/null +++ b/tests/tests_data/win/puzzle2_data.json @@ -0,0 +1 @@ +{"info": {}, "data": {"init": {"open_path": ""}}} \ No newline at end of file diff --git a/tests/tests_data/win/puzzle2_result.bat b/tests/tests_data/win/puzzle2_result.bat new file mode 100644 index 0000000..e69de29 diff --git a/tests/tests_data/win/task_set.yml b/tests/tests_data/win/task_set.yml new file mode 100644 index 0000000..4310d74 --- /dev/null +++ b/tests/tests_data/win/task_set.yml @@ -0,0 +1,11 @@ +info: {} +data: + - step: pre + tasks: + - name: open + module: tasks.win.open_file + + - step: main + tasks: + - name: export + module: tasks.win.export_file \ No newline at end of file From e65cb6e28a0adb1baaa6a05620dec77a57368f57 Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 19:39:23 +0900 Subject: [PATCH 03/14] run_process split from PuzzleBatch --- src/puzzle2/PuzzleBatch.py | 191 +++----------------------------- src/puzzle2/batch_kicker.py | 72 +++++++++--- src/puzzle2/run_process.py | 212 ++++++++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+), 187 deletions(-) create mode 100644 src/puzzle2/run_process.py diff --git a/src/puzzle2/PuzzleBatch.py b/src/puzzle2/PuzzleBatch.py index 2f4b12a..f9d3a4d 100644 --- a/src/puzzle2/PuzzleBatch.py +++ b/src/puzzle2/PuzzleBatch.py @@ -3,9 +3,7 @@ import os import sys -import datetime import traceback -import subprocess import importlib from . import Puzzle @@ -27,7 +25,7 @@ def __init__(self, name="puzzle", **kwargs): kwargs["name"] = self.name super(PuzzleBatch, self).__init__(**kwargs) - def start(self, task_set_path, data_path, **kwargs): + def start(self, task_set_path, data_path, context_path, **kwargs): """ task_set_type: multi: multi task set in one file @@ -39,36 +37,32 @@ def _get(name, default, kwargs): elif name in os.environ: return os.environ[name] return default - self.result_path = kwargs["result_path"] - task_set_type = kwargs.get("task_set_type", "single") #multi task set in one file + task_set_type = kwargs.get("task_set_type", "single") # multi task set in one file keys = kwargs.get("keys") app = kwargs.get("app", "") - if "mayapy" in app: - import maya.standalone - maya.standalone.initialize() - - - context_path = _get("PUZZLE_CONTEXT_PATH", "", kwargs) module_directory_path = kwargs.get("module_directory_path") if module_directory_path: for each in [l for l in module_directory_path.split(";") if l != ""]: - if each not in sys.path: - sys.path.append(each) - + # Prepend to avoid conflicts with similarly named top-level packages (e.g., 'tasks') + norm_each = os.path.normpath(each) + normalized_paths = [os.path.normpath(p) for p in sys.path] + if norm_each not in normalized_paths: + sys.path.insert(0, norm_each) _, data = pz_config.read(data_path) _, pz_data = pz_config.read(task_set_path) - - if context_path != "": + if context_path: _, context_data = pz_config.read(context_path) else: context_data = None - + messages = [] + headers = [] if task_set_type == "single": self.play(pz_data, data, context_data) + headers.extend(self.logger.details.order) messages.extend(self.logger.details.get_all()) else: keys = [key.strip() for key in keys.split(";") if key != ""] @@ -80,21 +74,24 @@ def _get(name, default, kwargs): for key in keys: self.play(pz_data[key], data, context_data) + headers.extend(self.logger.details.order) messages.extend(self.logger.details.get_all()) context_data = self.context self.close_event(app, messages, kwargs.get("close_app", True)) - return messages + return headers, messages, self.context def close_event(self, app, messages, close_app): def _close(): flg = True - + # Skip when app is not specified + if not app: + return True try: addon = importlib.import_module("puzzle2.addons.{}.integration".format(app)) reload(addon) except ImportError: - print(traceback.format_exc()) + # Addon for the specified app is not available; skip closing quietly return False if hasattr(addon, "close_event"): @@ -104,162 +101,8 @@ def _close(): if self.result_path: pz_config.save(self.result_path, messages) - if "mayapy" in app: - maya.standalone.uninitialize() if close_app: print("close") _close() -def run_process(app, **kwargs): - """ - app like: maya, mayapy, motionbuilder, mobupy, 3dsmax, 3dsmaxpy - version like: 2016, 2017, 2018, 2019+ - - job_directory: job file to give to batch_kicker.py - """ - def _get_script_path(script, app): - if script is None: - script_root = pz_env.get_puzzle_path() - script_path = "{}/addons/{}/batch_kicker.py".format(script_root, app) - if not os.path.exists(script_path): - script_path = "{}/batch_kicker.py".format(script_root) - - return os.path.normpath(script_path) if os.path.exists(script_path) else False - - def _get_addon(app, launcher): - """ - check customs addon then check defaults - """ - if launcher: - app = launcher - script_root = pz_env.get_puzzle_path() - path = "{}/addons/customs/{}".format(script_root, app) - if os.path.exists(path): - addon_path = "puzzle2.addons.customs.{}.integration".format(app) - addon_path = "puzzle2.addons.{}.integration".format(app) - - try: - addon = importlib.import_module(addon_path) - reload(addon) - return addon - - except ImportError: - print(traceback.format_exc()) - return False - - kwargs["app"] = app - kwargs.setdefault("puzzle_directory", os.path.dirname(pz_env.get_puzzle_path())) - kwargs.setdefault("log_name", "puzzle") - - """ - like rez - """ - addon = _get_addon(app, kwargs.get("launcher", False)) - print(addon) - if not addon: - return False - - kwargs["script_path"] = _get_script_path(kwargs.get("script_path", None), app) - if not kwargs["script_path"]: - return False - - if "start_signal" in kwargs: - kwargs["start_signal"].emit() - - - now = datetime.datetime.now().strftime("%Y%m%d%H%M%S") - job_directory = "{}/{}".format(pz_env.get_temp_directory(subdir="puzzle/jobs"), now) - - job_directory = kwargs.get("job_directory", job_directory) - - if isinstance(kwargs.get("task_set"), list): - kwargs["task_set_path"] = "{}/tasks.json".format(job_directory) - pz_config.save(kwargs["task_set_path"], kwargs["task_set"]) - kwargs["task_set_type"] = "single" - - if isinstance(kwargs.get("data_set"), dict): - kwargs["data_path"] = "{}/data.json".format(job_directory) - pz_config.save(kwargs["data_path"], kwargs["data_set"]) - - env_data = { - "app": app, - "puzzle_directory": kwargs["puzzle_directory"], - "log_name": kwargs.get("log_name", "puzzle"), - "log_directory": kwargs.get("log_directory", job_directory), - "keys": kwargs.get("keys", ""), - "script_path": kwargs["script_path"], - "task_set": kwargs.get("task_set", False), - "data_set": kwargs.get("data_set", False), - "module_directory_path": kwargs.get("module_directory_path", False), - "module_name": kwargs.get("module_name", False), - "module_path": kwargs.get("module_path", False), - "close_app": kwargs.get("close_app", False), - "sys_path": kwargs.get("sys_path", False) - } - - if "context_path" in kwargs: - env_data["context_path"] = kwargs["context_path"] - - env_data["result_path"] = kwargs.get("result_path", "{}/results.json".format(job_directory)) - - env_copy = os.environ.copy() - if hasattr(addon, "add_env"): - for k, v in addon.add_env(**kwargs).items(): - env_data[k] = str(v) - - job_path = "{}/config.json".format(job_directory) - pz_config.save(job_path, - {"env": env_data, - "data_path": kwargs["data_path"], - "task_set_path": kwargs["task_set_path"]}) - - env_copy["PUZZLE_JOB_PATH"] = job_path - kwargs["job_path"] = job_path - command = addon.get_command(**kwargs) - - if kwargs.get("bat_file"): - bat = "" - for k, v in env_copy.items(): - if k.startswith("PUZZLE_"): - bat += "SET {}={}\n".format(k, v) - - bat += command - - # create bat file - if not os.path.exists(os.path.dirname(kwargs["bat_file"])): - os.makedirs(os.path.dirname(kwargs["bat_file"])) - - with open(kwargs["bat_file"], "w") as f: - f.write(bat) - - if kwargs.get("bat_file"): - if kwargs.get("bat_start", False): - # return bat file path when bat_start flag is False - return kwargs["bat_file"], job_directory - else: - command = kwargs["bat_file"] - - print("command : {}".format(command)) - print("log directory : {}".format(env_data["log_directory"])) - print("config directory : {}".format(os.path.dirname(job_path))) - print("") - process = subprocess.Popen(command, - env=env_copy, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - shell=False) - - results = process.communicate() - - if results[1] != "": - with open("{}/std.txt".format(job_directory), "w") as f: - f.write("{}\n{}".format(str(results[0]), str(results[1]))) - - if "end_signal" in kwargs: - kwargs["end_signal"].emit(kwargs) - - process.stdout.close() - process.stderr.close() - - return command, job_directory diff --git a/src/puzzle2/batch_kicker.py b/src/puzzle2/batch_kicker.py index a97c339..e6b6c36 100644 --- a/src/puzzle2/batch_kicker.py +++ b/src/puzzle2/batch_kicker.py @@ -2,13 +2,27 @@ import sys import json +try: + import maya.standalone + maya.standalone.initialize("python") +except: + pass +class CustomEncoder(json.JSONEncoder): + def default(self, o): + return str(o) + def main(path=None): - if path: - pass - else: - path = os.environ["PUZZLE_JOB_PATH"] + # Resolve config path + if not path: + if "PUZZLE_JOB_PATH" in os.environ: + path = os.environ["PUZZLE_JOB_PATH"] + else: + root = sys.argv[-1] + path = "{}/config.json".format(root) + if not os.path.exists(path): + raise Exception("No config.json found in {}".format(root)) - js_data = {} + # Load job config if sys.version_info.major == 2: with open(path, "r") as f: js_data = json.load(f)["data"] @@ -16,23 +30,55 @@ def main(path=None): with open(path, "r", encoding="utf8") as f: js_data = json.load(f)["data"] + # Prepare sys.path for puzzle2 and optional extras puzzle_directory = js_data["env"]["puzzle_directory"] - sys.path.insert(0, puzzle_directory) - if "sys_path" in js_data["env"]: + if puzzle_directory not in [p.replace("\\", "/") for p in sys.path]: + sys.path.insert(0, puzzle_directory) + if "sys_path" in js_data["env"] and js_data["env"]["sys_path"]: for sys_path in [l for l in js_data["env"]["sys_path"].split(";") if l != ""]: - sys.path.append(sys_path) + if sys_path not in [p.replace("\\", "/") for p in sys.path]: + sys.path.append(sys_path) + # Now we can import internal modules import puzzle2.pz_env as pz_env from puzzle2.PuzzleBatch import PuzzleBatch + from puzzle2.PzLog import PzLog + # Initialize PzLog early to capture batch_kicker lifecycle log_directory = js_data["env"].get("log_directory", pz_env.get_log_directory()) log_name = js_data["env"].get("log_name", "puzzle") - batch = PuzzleBatch(log_directory=log_directory, - name=log_name) + Log = PzLog(name=log_name, log_directory=log_directory, new=True) + logger = Log.logger + logger.info("batch_kicker: start") + logger.debug("config path: {}".format(path)) + + # Run batch + # Pass the underlying logging.Logger to avoid wrapper mismatch + batch = PuzzleBatch(log_directory=log_directory, name=log_name, logger=logger) + headers, results, context = batch.start( + js_data["task_set_path"], + js_data["data_path"], + js_data.get("context_path", None), + **js_data["env"] + ) + + # Write results + result_path = js_data["env"]["result_path"] + if not os.path.exists(os.path.dirname(result_path)): + os.makedirs(os.path.dirname(result_path)) + + result_data = { + "info": {}, + "data": {"headers": headers, "results": results, "context": context}, + } + if sys.version_info.major == 2: + with open(result_path, "w") as f: + json.dump(result_data, f, indent=4, cls=CustomEncoder) + else: + with open(result_path, "w", encoding="utf8") as f: + json.dump(result_data, f, indent=4, cls=CustomEncoder) - batch.start(js_data["task_set_path"], - js_data["data_path"], - **js_data["env"]) + logger.info("batch_kicker: done -> {}".format(result_path)) if __name__ in ["__main__", "__builtin__", "builtins"]: diff --git a/src/puzzle2/run_process.py b/src/puzzle2/run_process.py new file mode 100644 index 0000000..aaf3049 --- /dev/null +++ b/src/puzzle2/run_process.py @@ -0,0 +1,212 @@ + +import os +import datetime +import traceback +import subprocess +import importlib + +from . import pz_env as pz_env +from . import pz_config as pz_config +from .PzLog import PzLog + +try: + reload +except NameError: + if hasattr(importlib, "reload"): + # for py3.4+ + from importlib import reload + + +def run_process(app, **kwargs): + """ + app like: maya, mayapy, motionbuilder, mobupy, 3dsmax, 3dsmaxpy + version like: 2016, 2017, 2018, 2019+ + + job_directory: job file to give to batch_kicker.py + """ + def _get_script_path(script, app): + if script is None: + script_root = pz_env.get_puzzle_path() + script_path = "{}/addons/{}/batch_kicker.py".format(script_root, app) + if not os.path.exists(script_path): + script_path = "{}/batch_kicker.py".format(script_root) + + return os.path.normpath(script_path) if os.path.exists(script_path) else False + + def _get_addon(app, launcher): + """ + check customs addon then check defaults + """ + if launcher: + app = launcher + script_root = pz_env.get_puzzle_path() + path = "{}/addons/customs/{}".format(script_root, app) + if os.path.exists(path): + addon_path = "puzzle2.addons.customs.{}.integration".format(app) + else: + # Fallback to the built-in addon path + addon_path = "puzzle2.addons.{}.integration".format(app) + + try: + addon = importlib.import_module(addon_path) + try: + # Reload only if it's a real module; tolerate monkeypatched objects + reload(addon) + except Exception: + pass + return addon + + except ImportError: + # print(traceback.format_exc()) + return False + + kwargs["app"] = app + kwargs.setdefault("puzzle_directory", os.path.dirname(pz_env.get_puzzle_path())) + kwargs.setdefault("log_name", "puzzle") + + """ + like rez + """ + addon = _get_addon(app, kwargs.get("launcher", False)) + if not addon: + # Logging will be initialized later once job_directory is known; early exit here + return False, False + kwargs["script_path"] = _get_script_path(kwargs.get("script_path", None), app) + if not kwargs["script_path"]: + return False, False + + if "start_signal" in kwargs: + kwargs["start_signal"].emit() + + now = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + job_directory = "{}/{}".format(pz_env.get_temp_directory(subdir="puzzle/jobs"), now) + job_directory = kwargs.get("job_directory", job_directory) + # Ensure the job directory exists before writing artifacts + if not os.path.exists(job_directory): + os.makedirs(job_directory, exist_ok=True) + + # Initialize a launcher-scoped logger writing alongside job logs + log_directory = kwargs.get("log_directory", job_directory) + base_log_name = kwargs.get("log_name", "puzzle") + launcher_log_name = f"{base_log_name}_launcher" + _Log = PzLog(name=launcher_log_name, log_directory=log_directory, new=True) + _logger = _Log.logger + _logger.info("run_process: start app=%s", app) + + if isinstance(kwargs.get("task_set"), list): + kwargs["task_set_path"] = "{}/tasks.json".format(job_directory) + pz_config.save(kwargs["task_set_path"], kwargs["task_set"]) + _logger.debug("wrote task_set to %s", kwargs["task_set_path"]) + kwargs["task_set_type"] = "single" + + if isinstance(kwargs.get("data_set"), dict): + kwargs["data_path"] = "{}/data.json".format(job_directory) + pz_config.save(kwargs["data_path"], kwargs["data_set"]) + _logger.debug("wrote data_set to %s", kwargs["data_path"]) + + if isinstance(kwargs.get("context"), dict): + kwargs["context_path"] = "{}/context.json".format(job_directory) + keys = list(kwargs["context"].keys()) + for key in keys: + if key.startswith("_"): + del kwargs["context"][key] + elif key == "logger": + del kwargs["context"][key] + pz_config.save(kwargs["context_path"], kwargs["context"]) + _logger.debug("wrote context to %s", kwargs["context_path"]) + if "data_path" not in kwargs or "task_set_path" not in kwargs: + _logger.critical("data_path or task_set_path missing in kwargs") + raise ValueError("data_path or task_set_path is not defined") + + env_data = { + "app": app, + "puzzle_directory": kwargs["puzzle_directory"], + "log_name": kwargs.get("log_name", "puzzle"), + "log_directory": kwargs.get("log_directory", job_directory), + "keys": kwargs.get("keys", ""), + "script_path": kwargs["script_path"], + "task_set": kwargs.get("task_set", False), + "data_set": kwargs.get("data_set", False), + "module_directory_path": kwargs.get("module_directory_path", False), + "module_name": kwargs.get("module_name", False), + "module_path": kwargs.get("module_path", False), + "close_app": kwargs.get("close_app", False), + "sys_path": kwargs.get("sys_path", False) + } + + env_data["result_path"] = kwargs.get("result_path", "{}/results.json".format(job_directory)) + + env_copy = os.environ.copy() + # Avoid creating .pyc / __pycache__ in spawned processes + env_copy["PYTHONDONTWRITEBYTECODE"] = "1" + if hasattr(addon, "add_env"): + for k, v in addon.add_env(**kwargs).items(): + env_data[k] = str(v) + + job_path = "{}/config.json".format(job_directory) + pz_config.save(job_path, + {"env": env_data, + "data_path": kwargs["data_path"], + "task_set_path": kwargs["task_set_path"], + "context_path": kwargs.get("context_path", None)}) + _logger.info("job config written: %s", job_path) + + env_copy["PUZZLE_JOB_PATH"] = job_path + kwargs["job_path"] = job_path + command = addon.get_command(**kwargs) + if not command: + _logger.error("Failed to resolve command for app=%s version=%s", app, kwargs.get("version", "")) + return False, job_directory + _logger.debug("BATCH COMMAND %s", command) + if kwargs.get("bat_file"): + bat = "" + for k, v in env_copy.items(): + if k.startswith("PUZZLE_"): + bat += "SET {}={}\n".format(k, v) + + bat += command + + # create bat file + if not os.path.exists(os.path.dirname(kwargs["bat_file"])): + os.makedirs(os.path.dirname(kwargs["bat_file"])) + + with open(kwargs["bat_file"], "w") as f: + f.write(bat) + _logger.info("bat file created: %s", kwargs["bat_file"]) + + if kwargs.get("bat_start"): + if kwargs.get("bat_start", False): + # return bat file path when bat_start flag is False + return kwargs["bat_file"], job_directory + else: + command = kwargs["bat_file"] + _logger.info("command: %s", command) + _logger.info("log directory: %s", env_data["log_directory"]) + _logger.info("config directory: %s", os.path.dirname(job_path)) + process = subprocess.Popen(command, + env=env_copy, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=False) + + results = process.communicate() + + if results[1]: + with open("{}/std.txt".format(job_directory), "w") as f: + f.write("{}\n{}".format(str(results[0]), str(results[1]))) + try: + _logger.error("stderr captured; see std.txt in job directory") + except Exception: + pass + + if "end_signal" in kwargs: + kwargs["end_signal"].emit(kwargs) + + process.stdout.close() + process.stderr.close() + + try: + _logger.info("run_process: done (returncode=%s)", getattr(process, "returncode", None)) + except Exception: + pass + return command, job_directory From eb02a926a19013d6ceb75209e0ceed6c02b93046 Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 20:03:01 +0900 Subject: [PATCH 04/14] PzLog: replace dictConfig to original function. Because it might effects to global logger. --- src/puzzle2/PzLog.py | 175 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 153 insertions(+), 22 deletions(-) diff --git a/src/puzzle2/PzLog.py b/src/puzzle2/PzLog.py index 37fce89..3ef6f63 100644 --- a/src/puzzle2/PzLog.py +++ b/src/puzzle2/PzLog.py @@ -42,6 +42,7 @@ def set_name(self, name): self.order.append(self.name) self._meta_data[self.name] = {} self.index += 1 + return self.index - 1, self.name def add_detail(self, text): self._details.setdefault(self.name, []).append(text) @@ -50,6 +51,10 @@ def set_header(self, return_code, text): self._header[self.name] = {"return_code": return_code, "header": text} + def set_data_location(self, location): + if self.name in self._meta_data: + self._meta_data[self.name]["location"] = location + def set_data_required(self, required): if self.name in self._meta_data: self._meta_data[self.name]["required"] = required @@ -71,13 +76,13 @@ def get_return_codes(self): return codes - def get_details(self, key=None): - if key: - return self._details[key] - else: - details = [] - for name in self.order: - details.append(self._details[name]) + def get_details(self, name): + data = copy.deepcopy(self._header[name]) + if name in self._details: + data["details"] = self._details[name] + if name in self._meta_data: + data["meta_data"] = self._meta_data[name] + return data return details @@ -97,6 +102,21 @@ def get_all(self): all_.append(data) return all_ + + def add_data_set(self, headers, results, location): + for header, result in zip(headers, results): + self.add_data(header, result, location) + + def add_data(self, header, result, location={}): + header_name = " ".join(header.split(" ")[1:]) + self.set_name(header_name) + self.set_header(result["return_code"], result["header"]) + for detail in result.get("details", []): + self.add_detail(detail) + + self.set_data_required(result.get("meta_data", {}).get("required", {})) + self.set_execution_time(result.get("meta_data", {}).get("execution_time", 0.0)) + self.set_data_location(location) def clear(self): self._header = {} @@ -206,9 +226,8 @@ def debug(self, msg, *args, **kwargs): self.update_ui(msg, "debug", **kwargs) -# Set PzLogger as the default Class to be called when using getLogger() -logging.setLoggerClass(PzLogger) - +# Note: Avoid setting a global LoggerClass to prevent process-wide side effects. +# If you need a PzLogger instance, PzLog will construct one locally. # log.success('Hello World') @@ -240,6 +259,14 @@ def __init__(self, name=None, new=False, **kwargs): name = "unknown" template_path = kwargs.get("template_file", pz_env.get_log_template()) + # If YAML isn't available but a YAML template was chosen, switch to JSON template + try: + from . import pz_config as _pz_config + if template_path.endswith('.yml') and (not getattr(_pz_config, 'YAML_AVAILABLE', False)): + template_path = pz_env.get_log_template_json() + except BaseException: + pass + self.log_directory = kwargs.get("log_directory", pz_env.get_log_directory("Pzlog")) self.base_template_path = None @@ -254,7 +281,6 @@ def __init__(self, name=None, new=False, **kwargs): reset_template = kwargs["use_default_config"] handler_levels = {k.replace("_level", ""): v for (k, v) in kwargs.items() if k.endswith("_level")} - if self.name in self.get_loggers().keys(): if new: self.remove_handlers() @@ -280,7 +306,8 @@ def __init__(self, name=None, new=False, **kwargs): if kwargs.get("clear", False): # Delete previous log file if exists - os.remove(self.log_path) + if os.path.exists(self.log_path): + os.remove(self.log_path) max_log_count = kwargs.get("max_log_count", 0) if max_log_count > 0: @@ -309,7 +336,7 @@ def __init__(self, name=None, new=False, **kwargs): except BaseException: import traceback traceback.print_exc() - + if not os.path.exists(self.config_path): # Create a new config file from the template config file append_loggers = { @@ -325,6 +352,7 @@ def __init__(self, name=None, new=False, **kwargs): } } + # Use the selected base template path (YAML or JSON depending on availability) self.base_template_path = template_path else: self.base_template_path = self.config_path @@ -333,9 +361,8 @@ def __init__(self, name=None, new=False, **kwargs): config_data = self.create_log_config_file(append_handers, append_loggers) - logging.config.dictConfig(config_data) - - self.logger = getLogger(self.name) + # Build a scoped logger from config without applying global dictConfig + self.logger = self._build_logger_from_config(config_data) self.change_handler_levels(**handler_levels) # check: propagate is always False @@ -352,12 +379,7 @@ def get_loggers(self): return logging.Logger.manager.loggerDict def create_log_config_file(self, handers={}, loggers={}): - if not os.path.isdir(os.path.dirname(self.base_template_path)): - try: - os.makedirs(os.path.dirname(self.base_template_path)) - except BaseException: # Dir is created between the os.path.isdir and the os.makedirs calls - if not os.path.isdir(os.path.dirname(self.base_template_path)): - raise + # Read template (do not create template directory) info, data = pz_config.read(self.base_template_path) data.setdefault("handlers", {}) @@ -376,6 +398,115 @@ def create_log_config_file(self, handers={}, loggers={}): return data + def _build_logger_from_config(self, config_data): + """ + Build and configure only this logger from the given config dict + without applying global logging.config.dictConfig. + Supports the common fields used by puzzle templates + (formatters, stream_handler, file_handler, levels, handlers list). + """ + # Resolve target logger config (fallback to root-like defaults if missing) + logger_name = self.name + loggers_cfg = config_data.get("loggers", {}) + logger_cfg = loggers_cfg.get(logger_name, {}) + + # Create or get a PzLogger instance without changing the global LoggerClass + mgr = logging.Logger.manager + existing = mgr.loggerDict.get(logger_name) + if isinstance(existing, PzLogger): + logger = existing + else: + logger = PzLogger(logger_name) + mgr.loggerDict[logger_name] = logger + + # Ensure directory for file handler exists when needed + def _ensure_dir(path): + d = os.path.dirname(path) + if d and not os.path.isdir(d): + try: + os.makedirs(d) + except BaseException: + if not os.path.isdir(d): + raise + + # Build formatters + fmts_cfg = config_data.get("formatters", {}) + formatters = {} + for fname, fcfg in fmts_cfg.items(): + fmt = fcfg.get("format") + datefmt = fcfg.get("datefmt") + try: + formatters[fname] = logging.Formatter(fmt=fmt, datefmt=datefmt) + except Exception: + # Fallback minimal formatter + formatters[fname] = logging.Formatter(fmt) + + # Build handlers registry but only instantiate those referenced by our logger + wanted_handlers = logger_cfg.get("handlers") or [] + handlers_cfg = config_data.get("handlers", {}) + built_handlers = {} + for hname in wanted_handlers: + hcfg = handlers_cfg.get(hname) + if not hcfg: + continue + hclass = hcfg.get("class", "") + level = hcfg.get("level", "DEBUG") + formatter_name = hcfg.get("formatter") + + handler = None + if hclass.endswith("StreamHandler"): + stream_target = hcfg.get("stream", "ext://sys.stdout") + stream = sys.stdout if "stdout" in stream_target else sys.stderr + handler = logging.StreamHandler(stream) + elif hclass.endswith("FileHandler"): + filename = hcfg.get("filename", self.log_path) + mode = hcfg.get("mode", "a") + _ensure_dir(filename) + handler = logging.FileHandler(filename, mode=mode, encoding="utf-8") + + if handler is None: + # Unsupported handler class; skip quietly to avoid global changes + continue + + # Name the handler so change_handler_levels can find it + try: + handler.set_name(hname) + except AttributeError: + handler.name = hname # older Python fallback + + # Level and formatter + try: + handler.setLevel(getattr(logging, level.upper(), logging.DEBUG)) + except Exception: + handler.setLevel(logging.DEBUG) + + if formatter_name and formatter_name in formatters: + handler.setFormatter(formatters[formatter_name]) + + built_handlers[hname] = handler + + # Apply handlers to logger (reset if we've removed template previously or asked new=True upstream) + # Remove only handlers attached to this logger instance + for h in list(logger.handlers): + try: + logger.removeHandler(h) + except Exception: + pass + + for hname in wanted_handlers: + if hname in built_handlers: + logger.addHandler(built_handlers[hname]) + + # Level and propagate + level_name = logger_cfg.get("level", "DEBUG") + try: + logger.setLevel(getattr(logging, level_name.upper(), logging.DEBUG)) + except Exception: + logger.setLevel(logging.DEBUG) + logger.propagate = False + + return logger + def remove_handlers(self): """ Remove all handlers attached to self.name """ From d3fd15f0ce84df9f092171f64a3a0e7f6930d6de Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 20:04:22 +0900 Subject: [PATCH 05/14] add json capasity for the location not using pyyaml --- src/puzzle2/pz_config.py | 29 ++++++++++++++++++++++------- src/puzzle2/pz_env.py | 4 ++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/puzzle2/pz_config.py b/src/puzzle2/pz_config.py index ac0a816..44a91e5 100644 --- a/src/puzzle2/pz_config.py +++ b/src/puzzle2/pz_config.py @@ -4,11 +4,16 @@ import json import codecs +YAML_AVAILABLE = False try: - import yaml + import yaml # type: ignore + YAML_AVAILABLE = True except BaseException: - import traceback - traceback.print_exc() + yaml = None # Fallback when PyYAML is not available + +class CustomEncoder(json.JSONEncoder): + def default(self, o): + return str(o) def read(path): """ @@ -20,13 +25,19 @@ def read(path): if sys.version_info.major == 2: with codecs.open(path, "r") as f: if path.endswith(".yml"): - data = yaml.load(f, Loader=yaml.SafeLoader) + if YAML_AVAILABLE and yaml is not None: + data = yaml.load(f, Loader=yaml.SafeLoader) + else: + raise RuntimeError("YAML support is not available; cannot read .yml: {}".format(path)) elif path.endswith(".json"): data = json.load(f, "utf8") else: with codecs.open(path, "r", "utf8") as f: if path.endswith(".yml"): - data = yaml.load(f, Loader=yaml.SafeLoader) + if YAML_AVAILABLE and yaml is not None: + data = yaml.load(f, Loader=yaml.SafeLoader) + else: + raise RuntimeError("YAML support is not available; cannot read .yml: {}".format(path)) elif path.endswith(".json"): data = json.load(f) @@ -58,15 +69,19 @@ def save(path, data, tool_name="", category="", version="", extend_info={}): if sys.version_info.major == 2: with codecs.open(path, "w") as f: if path.endswith(".yml"): + if not YAML_AVAILABLE or yaml is None: + raise RuntimeError("YAML support is not available; cannot write .yml: {}".format(path)) yaml.dump(info_data, f, default_flow_style=False, allow_unicode=True) elif path.endswith(".json"): - json.dump(info_data, f, indent=4, ensure_ascii=False) + json.dump(info_data, f, indent=4, ensure_ascii=False, cls=CustomEncoder) elif sys.version_info.major == 3: with codecs.open(path, "w", "utf8") as f: if path.endswith(".yml"): + if not YAML_AVAILABLE or yaml is None: + raise RuntimeError("YAML support is not available; cannot write .yml: {}".format(path)) yaml.dump(info_data, f, default_flow_style=False, allow_unicode=True) elif path.endswith(".json"): - json.dump(info_data, f, indent=4, ensure_ascii=False) + json.dump(info_data, f, indent=4, ensure_ascii=False, cls=CustomEncoder) return True diff --git a/src/puzzle2/pz_env.py b/src/puzzle2/pz_env.py index 67d66f8..253f22a 100644 --- a/src/puzzle2/pz_env.py +++ b/src/puzzle2/pz_env.py @@ -40,8 +40,12 @@ def get_puzzle_module_path(): return os.path.dirname(get_puzzle_path()).replace("\\", "/") def get_log_template(): + # Prefer YAML template by default; pz_config will error if YAML isn't available return "{}/log.template.yml".format(get_puzzle_path()) +def get_log_template_json(): + return "{}/log.template.json".format(get_puzzle_path()) + def get_temp_directory(subdir=""): path = "%s/%s" % (TEMP_PATH, subdir) if not os.path.isdir(path): From 7a05e8440ceb69fc0bdacc98a9ad29c31c11245a Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 20:11:54 +0900 Subject: [PATCH 06/14] add "pipe" step function --- src/puzzle2/Puzzle.py | 60 +++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/src/puzzle2/Puzzle.py b/src/puzzle2/Puzzle.py index f5af1c1..26d0a9b 100644 --- a/src/puzzle2/Puzzle.py +++ b/src/puzzle2/Puzzle.py @@ -8,11 +8,11 @@ import datetime import traceback import importlib -import subprocess from . import PzLog from . import pz_env as pz_env from . import pz_config as pz_config +from . import run_process as run_process from .PzTask import PzTask try: @@ -68,6 +68,7 @@ def __init__(self, name="puzzle", **kwargs): if task_directory not in sys.path: sys.path.append(task_directory) + def execute_step(self, tasks, data, common, step): """ when data is list, same tasks run for each. @@ -96,6 +97,7 @@ def execute_step(self, tasks, data, common, step): "common" is special keyword. we can use them everywhere """ + temp_common = copy.deepcopy(common) temp_common.update(data) data = temp_common @@ -138,7 +140,7 @@ def execute_task(self, task, data): task.setdefault("name", module_path.split(".")[-1]) # initialize details log - self.logger.details.set_name(task["name"]) + task_index, index_name = self.logger.details.set_name(task["name"]) self.logger.details.set_header(0, "successed: {}".format(task["name"])) if "comment" in task and task["comment"] != "": self.logger.details.add_detail(task["comment"]) @@ -172,14 +174,14 @@ def execute_task(self, task, data): response = {"return_code": 0} self.logger.details.update_code(response["return_code"]) + return response def execute(self, task, data, module): task = PzTask(module=module, - task=task, - data=data, - context=self.context) - + task=task, + data=data, + context=self.context) response = task.execute() # {"return_code": A, "data_globals": B} if "update_context" in response: @@ -269,18 +271,42 @@ def _append_sys_path(path): self.logger.debug("break: {}".format(step_name)) break - [_append_sys_path(l) for l in step.get("sys_path", "").split(";") if l != ""] - - now = datetime.datetime.now() data_set.setdefault(step_name, {}) - self.logger.debug("- {} start - {}".format(step_name, step.get("comment", ""))) - - self.execute_step(tasks=step["tasks"], - data=data_set[step_name], - common=common, - step=step_name) - - self.logger.info("- {} takes: {}-\n".format(step_name, datetime.datetime.now() - now)) + if "pipe" in step: + pipe_data = copy.deepcopy(step["pipe"]) + pipe_data["task_set"] = step["tasks"] + pipe_data["data_set"] = data_set + pipe_data["context"] = copy.deepcopy(self.context) + pipe_data["close_app"] = step.get("close_app", True) + if "sys_path" in step and step["sys_path"]: + pipe_data["sys_path"] = step["sys_path"] + response = run_process.run_process(**pipe_data) + command, job_directory = response + result_path = "{}/results.json".format(job_directory) + + if command: + if os.path.exists(result_path): + info, data = pz_config.read(result_path) + self.logger.details.add_data_set(data["headers"], + data["results"], + location=step["pipe"]) + + for key, value in data["context"].items(): + if key.startswith("_") or key == "logger": + continue + self.context[key] = value + + else: + [_append_sys_path(l) for l in step.get("sys_path", "").split(";") if l != ""] + now = datetime.datetime.now() + self.logger.debug("- {} start - {}".format(step_name, step.get("comment", ""))) + + self.execute_step(tasks=step["tasks"], + data=data_set[step_name], + common=common, + step=step_name) + + self.logger.info("- {} takes: {}-\n".format(step_name, datetime.datetime.now() - now)) if steps[-1]["step"] == "closure": self.break_ = False From 356612fe6976089a653b796aef48433b08e486df Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 22:54:29 +0900 Subject: [PATCH 07/14] add houdini --- src/puzzle2/addons/houdini/__init__.py | 0 src/puzzle2/addons/houdini/integration.py | 38 +++++++++++++++++++ src/puzzle2/addons/maya/integration.py | 15 ++++---- src/puzzle2/addons/mayabatch/integration.py | 35 ++++++++++++++--- src/puzzle2/addons/mayapy/integration.py | 4 +- .../addons/motionbuilder/integration.py | 4 ++ src/puzzle2/log.template.json | 32 ++++++++++++++++ src/puzzle2/log.template.yml | 3 +- 8 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 src/puzzle2/addons/houdini/__init__.py create mode 100644 src/puzzle2/addons/houdini/integration.py create mode 100644 src/puzzle2/log.template.json diff --git a/src/puzzle2/addons/houdini/__init__.py b/src/puzzle2/addons/houdini/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/puzzle2/addons/houdini/integration.py b/src/puzzle2/addons/houdini/integration.py new file mode 100644 index 0000000..a50784c --- /dev/null +++ b/src/puzzle2/addons/houdini/integration.py @@ -0,0 +1,38 @@ +import os + + +def get_app_path(version: str = "", program_directory: str = None) -> str: + """ + Resolve hython.exe for Houdini. + Default path example: + C:/Program Files/Side Effects Software/Houdini 20.0/bin/hython.exe + """ + # Explicit override via environment variable takes precedence + # Use PUZZLE_HYTHON_PATH to avoid collision with other tools' env names + override = os.environ.get("PUZZLE_HYTHON_PATH") + if override: + return os.path.normpath(override) + + base = (program_directory or os.environ.get("PROGRAMFILES", "C:/Program Files")).replace("\\", "/") + # Accept versions like "20.0" or "19.5"; caller provides exact folder postfix + path = f"{base}/Side Effects Software/Houdini {version}/bin/hython.exe" if version else f"{base}/Side Effects Software/bin/hython.exe" + return os.path.normpath(path) + + +def close_event(): + # Nothing specific to close for hython batch runs + return True + + +def get_command(**kwargs): + version = kwargs.get("version", "") + script_path = kwargs.get("script_path", "") + app_path = get_app_path(version, kwargs.get("program_directory")) + if not app_path or not os.path.exists(app_path): + return False + + if "launcher" in kwargs: + # When launched by another launcher (e.g., rez), omit the app path here + return f' "{script_path}"' + else: + return f'"{app_path}" "{script_path}"' diff --git a/src/puzzle2/addons/maya/integration.py b/src/puzzle2/addons/maya/integration.py index 7d90d36..3e0a778 100644 --- a/src/puzzle2/addons/maya/integration.py +++ b/src/puzzle2/addons/maya/integration.py @@ -8,13 +8,13 @@ def get_app_path(version, root_path=None): return path if os.path.exists(path) else False def close_event(): - import maya.mel as mm try: - mm.eval('scriptJob -cf "busy" "quit -f -ec 0";') - flg = True + import maya.cmds as cmds + cmds.quit(force=True, exitCode=0) + return True except BaseException: print(traceback.format_exc()) - flg = False + return False def get_command(version, **kwargs): app_path = get_app_path(version, kwargs.get("root_path", None)) @@ -24,11 +24,12 @@ def get_command(version, **kwargs): puzzle_directory = kwargs["puzzle_directory"] job_path = kwargs["job_path"] - cmd = '{} -command '.format(app_path) - cmd += '"python(\\\"import sys;import os;sys.path.append(\\\\\\"{}\\\\\\");'.format(puzzle_directory) + # Prefer running in batch to avoid GUI lingering + cmd = '{} -batch -command '.format(app_path) + cmd += '"python(\\"import sys;import os;sys.path.append(\\\\\\"{}\\\\\\");'.format(puzzle_directory) if "sys_path" in kwargs: for path in [l for l in kwargs["sys_path"].split(";") if l != ""]: cmd += 'sys.path.append(\\\\\\"{}\\\\\\");'.format(path) cmd += 'import puzzle2.batch_kicker as batch_kicker;batch_kicker.main(\\\\\\"{}\\\\\\");'.format(job_path) - cmd += 'x.start()\\\");' + cmd += '\");' return cmd \ No newline at end of file diff --git a/src/puzzle2/addons/mayabatch/integration.py b/src/puzzle2/addons/mayabatch/integration.py index b4cf170..58dc90b 100644 --- a/src/puzzle2/addons/mayabatch/integration.py +++ b/src/puzzle2/addons/mayabatch/integration.py @@ -1,13 +1,25 @@ import os + def get_app_path(version, program_directory=None): - path = False program_directory = program_directory or os.environ["PROGRAMFILES"].replace("\\", "/") - path = "{}/Autodesk/Maya{}/bin/mayabatch.exe".format(program_directory, version) - return path + if int(version) >= 2022: + path = "{}/Autodesk/Maya{}/bin/maya.exe".format(program_directory, version) + else: + path = "{}/Autodesk/Maya{}/bin/mayabatch.exe".format(program_directory, version) + + + return path if os.path.exists(path) else False + def close_event(): - return True + try: + import maya.cmds as cmds + cmds.quit(force=True, exitCode=0) + return True + except Exception: + return False + def get_command(**kwargs): version = kwargs["version"] @@ -16,7 +28,18 @@ def get_command(**kwargs): if not app_path: return False + # Use Python 3 compatible execution (Maya 2022+): runpy.run_path with __main__ + py_expr = ( + r"import runpy; " + r"runpy.run_path(\\\"{}\\\", run_name=\\\"__main__\\\")".format(script_path.replace("\\", "/")) + ) + if "launcher" in kwargs: - return r''' -command "python(\\"execfile('{}')\\");"'''.format(app_path, script_path) + # When launched via another launcher (e.g., rez), omit the app path here + return ' -command "python("{}\");"'.format(py_expr) else: - return r'''"{}" -command "python(\\"execfile('{}')\\");"'''.format(app_path, script_path) + if int(version) >= 2022: + return '"{}" -batch -command "python(\\"{}\\");"'.format(app_path, py_expr) + else: + return '"{}" -command "mayabatch -c \'{}\';"'.format(app_path, py_expr) + diff --git a/src/puzzle2/addons/mayapy/integration.py b/src/puzzle2/addons/mayapy/integration.py index 39b661f..d1b60c4 100644 --- a/src/puzzle2/addons/mayapy/integration.py +++ b/src/puzzle2/addons/mayapy/integration.py @@ -13,10 +13,12 @@ def _get_app_path(version, program_directory=None): version = kwargs.get("version", "") script_path = kwargs.get("script_path", "") app_path = _get_app_path(version, kwargs.get("program_directory", None)) - if not app_path: + if not app_path or not os.path.exists(app_path): return False + # Keep API consistent with other addons: return a quoted string command if "launcher" in kwargs: return r'"{}"'.format(script_path) else: + print(r'mayapy::: "{}" "{}"'.format(app_path, script_path)) return r'"{}" "{}"'.format(app_path, script_path) \ No newline at end of file diff --git a/src/puzzle2/addons/motionbuilder/integration.py b/src/puzzle2/addons/motionbuilder/integration.py index f05de99..5d39e1e 100644 --- a/src/puzzle2/addons/motionbuilder/integration.py +++ b/src/puzzle2/addons/motionbuilder/integration.py @@ -15,6 +15,10 @@ def _get_app_path(version, program_directory=None): script_path = kwargs["script_path"] app_path = _get_app_path(version, kwargs.get("program_directory", None)) + # Verify executable exists + if not os.path.exists(app_path): + return False + if "launcher" in kwargs: return r' -suspendMessages -g 50 50 "{}"'.format(app_path, script_path) else: diff --git a/src/puzzle2/log.template.json b/src/puzzle2/log.template.json new file mode 100644 index 0000000..f3d144e --- /dev/null +++ b/src/puzzle2/log.template.json @@ -0,0 +1,32 @@ +{ + "info": { + "name": {}, + "category": "", + "version": "" + }, + "data": { + "version": 1, + "disable_existing_loggers": false, + "formatters": { + "simple_formatter": { + "datefmt": "%m-%d-%y %H:%M:%S", + "format": "%(asctime)-25s %(levelname)-10s %(module)-20s %(funcName)-25s line:%(lineno)-5s %(message)s" + } + }, + "handlers": { + "stream_handler": { + "class": "logging.StreamHandler", + "level": "DEBUG", + "formatter": "simple_formatter", + "stream": "ext://sys.stdout" + }, + "file_handler": { + "class": "logging.FileHandler", + "level": "DEBUG", + "formatter": "simple_formatter", + "filename": "logfile.log", + "mode": "a" + } + } + } +} diff --git a/src/puzzle2/log.template.yml b/src/puzzle2/log.template.yml index 2eb5a77..b5b84bb 100644 --- a/src/puzzle2/log.template.yml +++ b/src/puzzle2/log.template.yml @@ -15,9 +15,8 @@ data: stream_handler: class: logging.StreamHandler formatter: simple_formatter - level: DEBUG + level: CRITICAL stream: ext://sys.stdout - version: 1 info: category: '' From 378fe51e3e5d866564faeb677e8acf449e22612e Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 23:02:17 +0900 Subject: [PATCH 08/14] add deadline plugins --- src/puzzle2/plugins/__init__.py | 1 + src/puzzle2/plugins/deadline/__init__.py | 1 + .../deadline/_staging/PluginPreLoad.py | 10 + .../plugins/deadline/_staging/README.md | 11 + .../plugins/deadline/_staging/puzzle2.param | 33 +++ src/puzzle2/plugins/deadline/client.py | 172 +++++++++++++++ .../deadline/plugins/puzzle2/puzzle2.py | 205 ++++++++++++++++++ 7 files changed, 433 insertions(+) create mode 100644 src/puzzle2/plugins/__init__.py create mode 100644 src/puzzle2/plugins/deadline/__init__.py create mode 100644 src/puzzle2/plugins/deadline/_staging/PluginPreLoad.py create mode 100644 src/puzzle2/plugins/deadline/_staging/README.md create mode 100644 src/puzzle2/plugins/deadline/_staging/puzzle2.param create mode 100644 src/puzzle2/plugins/deadline/client.py create mode 100644 src/puzzle2/plugins/deadline/plugins/puzzle2/puzzle2.py diff --git a/src/puzzle2/plugins/__init__.py b/src/puzzle2/plugins/__init__.py new file mode 100644 index 0000000..c33350a --- /dev/null +++ b/src/puzzle2/plugins/__init__.py @@ -0,0 +1 @@ +# Namespace package for puzzle2 plugins diff --git a/src/puzzle2/plugins/deadline/__init__.py b/src/puzzle2/plugins/deadline/__init__.py new file mode 100644 index 0000000..633049c --- /dev/null +++ b/src/puzzle2/plugins/deadline/__init__.py @@ -0,0 +1 @@ +# Deadline plugin helpers and client API diff --git a/src/puzzle2/plugins/deadline/_staging/PluginPreLoad.py b/src/puzzle2/plugins/deadline/_staging/PluginPreLoad.py new file mode 100644 index 0000000..d5c107e --- /dev/null +++ b/src/puzzle2/plugins/deadline/_staging/PluginPreLoad.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +import sys +from Deadline.Scripting import RepositoryUtils + +def __main__(): + pluginConfig = RepositoryUtils.GetPluginConfig("puzzle2", None) + for key in ("PuzzleDirectory", "TaskDirectory", "YamlDirectory"): + directory = pluginConfig.GetConfigEntryWithDefault(key, None) + if directory and directory not in sys.path: + sys.path.append(directory) diff --git a/src/puzzle2/plugins/deadline/_staging/README.md b/src/puzzle2/plugins/deadline/_staging/README.md new file mode 100644 index 0000000..b11acab --- /dev/null +++ b/src/puzzle2/plugins/deadline/_staging/README.md @@ -0,0 +1,11 @@ +This folder holds new Deadline plugin scaffold files staged for review. + +To deploy: +- Copy plugins/puzzle2/puzzle2.py to your repository's custom plugin path. +- Copy _staging/PluginPreLoad.py to plugins/puzzle2/PluginPreLoad.py in the repository. +- Copy _staging/puzzle2.param to plugins/puzzle2/puzzle2.param in the repository. +- (Optional) Copy scripts/Submission/SubmitPuzzle2Deadline.py to Repository/scripts/Submission. + +Notes: +- The plugin overlays addon-built command with Application Plugin executable when available. +- PUZZLE_JOB_PATH is set to the generated config.json for batch_kicker. diff --git a/src/puzzle2/plugins/deadline/_staging/puzzle2.param b/src/puzzle2/plugins/deadline/_staging/puzzle2.param new file mode 100644 index 0000000..12c6a51 --- /dev/null +++ b/src/puzzle2/plugins/deadline/_staging/puzzle2.param @@ -0,0 +1,33 @@ +[About] +Type=label +Label=About +Category=About Plugin +CategoryOrder=-1 +Default=Puzzle2 Plugin for Deadline (new scaffold) +Description=Executes puzzle2 batch_kicker with DCC executables from Application Plugin. + +[UseAppPluginPath] +Type=boolean +Label=Use Application Plugin Executable +Default=True +Description=Overlay addon-built command with the executable resolved from the Application Plugin. + +[PuzzleDirectory] +Type=Folder +Label=Puzzle2 Directory +Description=Path to 'src' containing the puzzle2 package. + +[TaskDirectory] +Type=Folder +Label=Task Module Directory +Description=Additional module directory (e.g., tests) added to sys.path. + +[YamlDirectory] +Type=Folder +Label=YAML Directory +Description=Optional: Directory that contains 'yaml' module if not installed system-wide. + +[JobDirectory] +Type=Folder +Label=Job Output Directory +Description=Where per-job folders are created (config.json/results). diff --git a/src/puzzle2/plugins/deadline/client.py b/src/puzzle2/plugins/deadline/client.py new file mode 100644 index 0000000..dd94c96 --- /dev/null +++ b/src/puzzle2/plugins/deadline/client.py @@ -0,0 +1,172 @@ +""" +Lightweight Deadline submission client for puzzle2. + +Use from tools/UX code to submit a puzzle2 job to Deadline without duplicating +pytest boilerplate. It writes Job/Plugin Info files (UTF-16LE by default), +resolves deadlinecommand, submits, and returns a structured result. + +Note: waiting/polling for result files should be handled by the caller. +""" + +import os +import shutil +import tempfile +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional, Tuple + + +@dataclass +class SubmissionResult: + """Submission outcome and useful paths/metadata.""" + + job_info_path: Path + plugin_info_path: Path + result_path: Path + completed: subprocess.CompletedProcess + job_id: Optional[str] = None + + +def _norm_posix(p: str) -> str: + """Normalize path to posix style for Plugin Info values.""" + return Path(p).as_posix() + + +def resolve_deadline_command() -> Optional[str]: + """Resolve path to `deadlinecommand` executable. + + Resolution order: + 1) DEADLINE_COMMAND env var (file or folder containing deadlinecommand.exe) + 2) shutil.which("deadlinecommand(.exe)") + 3) Common Program Files locations (Windows) + """ + cand = os.environ.get("DEADLINE_COMMAND") + if cand: + cand = cand.strip().strip('"') + cand = os.path.normpath(os.path.expandvars(os.path.expanduser(cand))) + if os.path.isfile(cand): + return cand + guess = os.path.join(cand, "deadlinecommand.exe") + if os.path.isfile(guess): + return guess + + for name in ("deadlinecommand.exe", "deadlinecommand"): + path = shutil.which(name) + if path: + return path + + program_files = os.environ.get("ProgramFiles", r"C:\\Program Files") + candidates = [ + os.path.join(program_files, "Thinkbox", "Deadline10", "bin", "deadlinecommand.exe"), + os.path.join(program_files, "Thinkbox", "Deadline", "bin", "deadlinecommand.exe"), + ] + for p in candidates: + if os.path.isfile(p): + return p + return None + + +essential_job_keys = ("Plugin", "Name") +essential_plugin_keys = ("App", "Version", "ModulePath", "TaskPath", "DataPath", "ResultPath") + + +def _write_info_file(path: Path, lines: Dict[str, str], encoding: str = "utf-16le") -> None: + """Write Deadline .job-style key=value lines with the specified encoding.""" + path.parent.mkdir(parents=True, exist_ok=True) + content = "".join(f"{k}={v}\n" for k, v in lines.items()) + path.write_text(content, encoding=encoding) + + +def _default_paths(temp_dir: Optional[Path], app: str) -> Tuple[Path, Path]: + base = Path(temp_dir) if temp_dir else Path(tempfile.mkdtemp(prefix=f"puzzle2_deadline_{app}_")) + return base / f"job_info_{app}.job", base / f"plugin_info_{app}.job" + + +def submit( + *, + app: str, + version: str, + module_path: str, + task_path: str, + data_path: str, + result_path: str, + sys_path: Optional[str] = None, + job_name: Optional[str] = None, + deadline_cmd: Optional[str] = None, + temp_dir: Optional[str] = None, + encoding: str = "utf-16le", + job_info_extras: Optional[Dict[str, str]] = None, + plugin_info_extras: Optional[Dict[str, str]] = None, + env: Optional[Dict[str, str]] = None, +) -> SubmissionResult: + """Submit a puzzle2 job to Deadline. + + Minimal required args map to the puzzle2 Deadline plugin fields. + """ + dl_cmd = deadline_cmd or resolve_deadline_command() + if not dl_cmd: + raise FileNotFoundError("deadlinecommand not found. Set DEADLINE_COMMAND or install Deadline client.") + + job_info_path, plugin_info_path = _default_paths(Path(temp_dir) if temp_dir else None, app) + + # Job Info (minimal) + job_lines = { + "Plugin": "puzzle2", + "Name": job_name or f"Puzzle2 Submit [{app}]", + } + if job_info_extras: + job_lines.update(job_info_extras) + _write_info_file(job_info_path, job_lines, encoding=encoding) + + # Plugin Info + plugin_lines = { + "App": app, + "Version": version, + "ModulePath": _norm_posix(module_path), + "TaskPath": _norm_posix(task_path), + "DataPath": _norm_posix(data_path), + "ResultPath": _norm_posix(result_path), + } + if sys_path: + plugin_lines["SysPath"] = _norm_posix(sys_path) + if plugin_info_extras: + plugin_lines.update(plugin_info_extras) + _write_info_file(plugin_info_path, plugin_lines, encoding=encoding) + + # Submit + completed = subprocess.run( + [dl_cmd, str(job_info_path), str(plugin_info_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + shell=False, + env=({**os.environ, **env} if env else None), + ) + + # Try to extract JobID if present in output (best-effort) + job_id: Optional[str] = None + out = completed.stdout or "" + for line in out.splitlines(): + s = line.strip().lower() + if s.startswith("jobid="): + job_id = line.split("=", 1)[-1].strip() + break + if s.startswith("job id:"): + job_id = line.split(":", 1)[-1].strip() + break + + return SubmissionResult( + job_info_path=job_info_path, + plugin_info_path=plugin_info_path, + result_path=Path(result_path), + completed=completed, + job_id=job_id, + ) + + +__all__ = [ + "SubmissionResult", + "resolve_deadline_command", + "submit", +] diff --git a/src/puzzle2/plugins/deadline/plugins/puzzle2/puzzle2.py b/src/puzzle2/plugins/deadline/plugins/puzzle2/puzzle2.py new file mode 100644 index 0000000..891f973 --- /dev/null +++ b/src/puzzle2/plugins/deadline/plugins/puzzle2/puzzle2.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +import os +import datetime +import json + +from Deadline.Plugins import DeadlinePlugin, PluginType +from Deadline.Scripting import ClientUtils, RepositoryUtils + +# puzzle2 imports (PluginPreLoad should have appended PuzzleDirectory/TaskDirectory to sys.path) +from puzzle2 import pz_env as _pz_env # noqa: E402 +from puzzle2 import pz_config as _pz_config # noqa: E402 + + +def GetDeadlinePlugin(): + return Puzzle2Plugin() + + +def CleanupDeadlinePlugin(deadlinePlugin): + deadlinePlugin.Cleanup() + + +class Puzzle2Plugin(DeadlinePlugin): + def __init__(self): + super(Puzzle2Plugin, self).__init__() + self.InitializeProcessCallback += self.InitializeProcess + self.RenderExecutableCallback += self.RenderExecutable + self.RenderArgumentCallback += self.RenderArgument + + def Cleanup(self): + del self.InitializeProcessCallback + del self.RenderExecutableCallback + del self.RenderArgumentCallback + + def InitializeProcess(self): + self.SingleFramesOnly = True + self.PluginType = PluginType.Simple + # Avoid creating .pyc / __pycache__ on workers + self.SetEnvironmentVariable("PYTHONDONTWRITEBYTECODE", "1") + + # ---------------- Internal helpers ---------------- + def _build_job_config(self, app): + """Create job directory and config.json for puzzle2 batch_kicker. + Returns (job_directory, job_path, script_path). + """ + now = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + job_root = self.GetConfigEntryWithDefault("JobDirectory", None) + if not job_root: + job_root = os.path.join(os.path.expanduser("~"), "puzzle2_jobs") + job_directory = os.path.join(job_root, now) + try: + if not os.path.isdir(job_directory): + os.makedirs(job_directory) + except Exception: + pass + + # Inputs from Plugin Info + sys_path = self.GetPluginInfoEntryWithDefault("SysPath", "") + module_dir = self.GetPluginInfoEntryWithDefault("ModulePath", "") + task_path = self.GetPluginInfoEntryWithDefault("TaskPath", "") + data_path = self.GetPluginInfoEntryWithDefault("DataPath", "") + result_path = self.GetPluginInfoEntryWithDefault("ResultPath", os.path.join(job_directory, "results.json")) + + # Compute batch_kicker path inside puzzle2 package + script_path = os.path.normpath(os.path.join(_pz_env.get_puzzle_path(), "batch_kicker.py")) + + env_data = { + "app": app, + "puzzle_directory": os.path.dirname(_pz_env.get_puzzle_path()), + "log_name": "puzzle", + "log_directory": job_directory.replace("\\", "/"), + "keys": "", + "script_path": script_path.replace("\\", "/"), + "module_directory_path": module_dir.replace("\\", "/") if module_dir else False, + "module_name": False, + "module_path": False, + "close_app": True, + "sys_path": sys_path if sys_path else False, + } + env_data["result_path"] = result_path.replace("\\", "/") + + job_path = os.path.join(job_directory, "config.json") + payload = { + "env": env_data, + "data_path": data_path.replace("\\", "/") if data_path else None, + "task_set_path": task_path.replace("\\", "/") if task_path else None, + "context_path": None, + } + _pz_config.save(job_path.replace("\\", "/"), payload, "puzzle2", "deadline") + return job_directory, job_path, script_path + + def _get_app_plugin_name_and_keys(self, app): + """Map puzzle2 'app' to Deadline Application Plugin and the config key patterns. + Returns (pluginName, keyBase, altKeyBase) where keyBase is typical 'RenderExecutable'. + altKeyBase may be an alternative like 'HythonExecutable'. + """ + app = (app or "").lower() + if app in ("maya", "mayabatch", "mayaexe"): + return ("MayaBatch", "RenderExecutable", None) + if app in ("3dsmax", "3dsmaxpy"): + return ("3dsmax", "RenderExecutable", None) + if app in ("houdini", "hython"): + return ("Houdini", "RenderExecutable", "HythonExecutable") + # For python-runner variants (mayapy/mobupy), we usually keep addon-provided exe + return (None, None, None) + + def _resolve_exe_from_app_plugin(self, app, version): + """Try to read the executable path from Deadline's Application Plugin config. + Supports version-specific keys like RenderExecutable_2024. + """ + pluginName, keyBase, altKeyBase = self._get_app_plugin_name_and_keys(app) + if not pluginName or not keyBase: + return None + try: + cfg = RepositoryUtils.GetPluginConfig(pluginName, None) + except Exception: + return None + + # Version-specific key takes precedence + candidates = [] + if version: + candidates.append("%s_%s" % (keyBase, str(version))) + if altKeyBase: + candidates.append("%s_%s" % (altKeyBase, str(version))) + candidates.append(keyBase) + if altKeyBase: + candidates.append(altKeyBase) + + for key in candidates: + try: + exe = cfg.GetConfigEntryWithDefault(key, None) + except Exception: + exe = None + if exe and os.path.isfile(exe): + return exe + return None + + def _split_command(self, command): + """Naive split of a quoted command string into (exe, args). + Assumes Windows-style quoting for the first token. + """ + cmd = command.strip() + exe = cmd + args = "" + if not cmd: + return "", "" + if cmd[0] == '"': + # find closing quote + idx = cmd.find('"', 1) + while idx != -1 and idx + 1 < len(cmd) and cmd[idx + 1] != ' ': + idx = cmd.find('"', idx + 1) + if idx != -1: + exe = cmd[1:idx] + args = cmd[idx + 1:].lstrip() + else: + exe = cmd.strip('"') + args = "" + else: + parts = cmd.split(" ", 1) + exe = parts[0] + args = parts[1] if len(parts) > 1 else "" + return exe, args + + # ---------------- Deadline callbacks ---------------- + def RenderExecutable(self): + app = self.GetPluginInfoEntryWithDefault("App", "") + version = self.GetPluginInfoEntryWithDefault("Version", "") + + # Prepare job config and environment for batch_kicker + job_directory, job_path, script_path = self._build_job_config(app) + self.SetEnvironmentVariable("PUZZLE_JOB_PATH", job_path.replace("\\", "/")) + + # Build command via addon first, then overlay exe from App Plugin if available + try: + addon = __import__("puzzle2.addons.%s.integration" % app, fromlist=["integration"]) # type: ignore + except Exception as e: + self.FailRender("Failed to import addon for app '%s': %s" % (app, e)) + return None + + # Minimal kwargs for command construction + kwargs = { + "job_path": job_path.replace("\\", "/"), + "version": version, + "script_path": script_path.replace("\\", "/"), + } + command = addon.get_command(**kwargs) + if not command: + self.FailRender("Failed to build command for app '%s' version '%s'" % (app, version)) + return None + + exe_from_cmd, args = self._split_command(command) + exe_from_app = self._resolve_exe_from_app_plugin(app, version) + + # Decide which executable to use + use_app_plugin_path = self.GetConfigEntryWithDefault("UseAppPluginPath", "True") + use_app_plugin_path = str(use_app_plugin_path).strip().lower() in ("true", "1", "yes", "on") + if use_app_plugin_path and exe_from_app: + self._cached_args = args + return exe_from_app + # fallback to addon-provided exe + self._cached_args = args + return exe_from_cmd + + def RenderArgument(self): + # Use cached args from RenderExecutable + return self._cached_args if hasattr(self, "_cached_args") else "" From fb0e5f3da194aef773a153479ff74576d33ecb75 Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 23:04:38 +0900 Subject: [PATCH 09/14] add/update tests --- src/puzzle2/tests_support/matrix.py | 83 ++++++ tests/README.md | 14 + tests/conftest.py | 24 ++ tests/dcc/batch/test_bat_dcc.py | 42 +++ tests/dcc/batch/test_batch_dcc.py | 106 ++++++++ tests/dcc/deadline/test_submit.py | 204 +++++++++++++++ tests/dcc/houdini/README.md | 3 + tests/dcc/maya/README.md | 3 + tests/dcc/pipe/README.md | 3 + tests/dcc/pipe/test_pipe_dcc.py | 86 +++++++ tests/integration/test_batch_kicker_local.py | 73 ++++++ tests/integration/test_config_consistency.py | 22 ++ tests/integration/test_puzzle_core.py | 190 ++++++++++++++ tests/support/matrix.py | 75 ++++++ tests/unit/test_pzlog_scoped.py | 258 +++++++++++++++++++ tests/unit/test_pztask_unit.py | 70 +++++ tests/unit/test_run_process_artifacts.py | 74 ++++++ 17 files changed, 1330 insertions(+) create mode 100644 src/puzzle2/tests_support/matrix.py create mode 100644 tests/README.md create mode 100644 tests/conftest.py create mode 100644 tests/dcc/batch/test_bat_dcc.py create mode 100644 tests/dcc/batch/test_batch_dcc.py create mode 100644 tests/dcc/deadline/test_submit.py create mode 100644 tests/dcc/houdini/README.md create mode 100644 tests/dcc/maya/README.md create mode 100644 tests/dcc/pipe/README.md create mode 100644 tests/dcc/pipe/test_pipe_dcc.py create mode 100644 tests/integration/test_batch_kicker_local.py create mode 100644 tests/integration/test_config_consistency.py create mode 100644 tests/integration/test_puzzle_core.py create mode 100644 tests/support/matrix.py create mode 100644 tests/unit/test_pzlog_scoped.py create mode 100644 tests/unit/test_pztask_unit.py create mode 100644 tests/unit/test_run_process_artifacts.py diff --git a/src/puzzle2/tests_support/matrix.py b/src/puzzle2/tests_support/matrix.py new file mode 100644 index 0000000..a4019c9 --- /dev/null +++ b/src/puzzle2/tests_support/matrix.py @@ -0,0 +1,83 @@ +"""Centralized app/version matrix for tests. + +Allows one place to manage which DCC apps and versions are exercised by tests. +Supports simple overrides via environment variables: + +- PUZZLE_TEST_APPS: semicolon-separated pairs like + mayapy:2024;mayabatch:2024;mobupy:2024;houdini:20.5.654 + +- PUZZLE_TEST_MATRIX_JSON: JSON string mapping app->list of versions, e.g. + {"mayapy":["2024"], "houdini":["20.5.654"]} + +If both are provided, PUZZLE_TEST_MATRIX_JSON takes precedence. +""" + +from __future__ import annotations + +import json +import os +from typing import Dict, List, Sequence, Tuple + + +DEFAULT_MATRIX: Dict[str, List[str]] = { + "mayapy": ["2024"], + "mayabatch": ["2024"], + "mobupy": ["2024"], + "motionbuilder": ["2024"], + "houdini": ["20.5.654"], +} + + +def _parse_env_matrix() -> Dict[str, List[str]] | None: + raw_json = os.environ.get("PUZZLE_TEST_MATRIX_JSON") + if raw_json: + try: + data = json.loads(raw_json) + # Normalize to list of strings + return {str(k): [str(vv) for vv in (v or [])] for k, v in data.items()} + except Exception: + pass + + raw_pairs = os.environ.get("PUZZLE_TEST_APPS") + if raw_pairs: + matrix: Dict[str, List[str]] = {} + for part in raw_pairs.split(";"): + part = part.strip() + if not part: + continue + if ":" in part: + app, ver = part.split(":", 1) + else: + app, ver = part, "" + app = app.strip() + ver = ver.strip() + if not app: + continue + matrix.setdefault(app, []) + if ver: + matrix[app].append(ver) + if matrix: + return matrix + return None + + +def build_cases(matrix: Dict[str, Sequence[str]] | None = None) -> Tuple[List[Tuple[str, str]], List[str]]: + """Build (app, version) pairs and pytest ids from a matrix mapping. + + Returns: + CASES: list of (app, version) + IDS: list of readable ids like "mayapy-2024" + """ + src = _parse_env_matrix() or matrix or DEFAULT_MATRIX + cases: List[Tuple[str, str]] = [] + ids: List[str] = [] + for app, versions in src.items(): + for v in versions: + cases.append((app, v)) + ids.append(f"{app}-{v}") + return cases, ids + + +CASES, IDS = build_cases() + +__all__ = ["DEFAULT_MATRIX", "build_cases", "CASES", "IDS"] diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..55f5a80 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,14 @@ +# Tests layout + +This folder is organized for CI (GitHub Actions) and local DCC testing. + +- test_data/ # Shared fixtures and sample JSON/YAML used by tests +- unit/ # Fast, automation-friendly unit tests (no DCC) +- integration/ # End-to-end flows without launching DCCs +- dcc/ + - houdini/ # Houdini-specific tests (run with hython) + - maya/ # Maya-specific tests (mayabatch/mayapy) + - pipe/ # Cross-DCC pipeline tests + +CI should run `tests/unit` and `tests/integration` only. +`tests/dcc` require installed DCCs and are opt-in. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..badba4a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +"""Pytest configuration for test pathing and bytecode suppression.""" +import os +import sys + +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_ROOT = os.path.normpath(os.path.join(THIS_DIR, '..')) + +# 1) Ensure the project 'src' directory is importable for tests +SRC_DIR = os.path.join(PROJECT_ROOT, 'src') +if SRC_DIR not in [p.replace('\\', '/') for p in sys.path]: + sys.path.insert(0, SRC_DIR) + +# 2) Ensure test fixture modules (e.g., tasks.win.*) under tests/tests_data are importable +TESTS_DATA_DIR = os.path.join(THIS_DIR, 'tests_data') +if TESTS_DATA_DIR not in [p.replace('\\', '/') for p in sys.path]: + sys.path.insert(0, TESTS_DATA_DIR) + +# Avoid creating .pyc / __pycache__ during test runs +os.environ["PYTHONDONTWRITEBYTECODE"] = "1" +try: + import sys as _sys + _sys.dont_write_bytecode = True +except Exception: + pass diff --git a/tests/dcc/batch/test_bat_dcc.py b/tests/dcc/batch/test_bat_dcc.py new file mode 100644 index 0000000..a39001a --- /dev/null +++ b/tests/dcc/batch/test_bat_dcc.py @@ -0,0 +1,42 @@ +import os +import sys +import pytest + +from puzzle2.run_process import run_process + +# Opt-in gate: only run when PUZZLE_RUN_DCC_TESTS is one of 1/true/yes/on +_RUN_DCC = os.getenv("PUZZLE_RUN_DCC_TESTS", "").strip().lower() in ("1", "true", "yes", "on") +if not _RUN_DCC: + pytest.skip( + "Opt-in: requires Deadline client and repo plugin configured.", + allow_module_level=True, + ) + +BASE = os.path.dirname(os.path.abspath(__file__)) +PROJECT_ROOT = os.path.normpath(os.path.join(BASE, "..", "..", "..")) +TESTS_DATA = os.path.normpath(os.path.join(PROJECT_ROOT, "tests", "tests_data")) + + +def test_generate_bat_for_maya_2024(tmp_path): + task_path = os.path.join(TESTS_DATA, "win", "task_set.yml") + data_path = os.path.join(TESTS_DATA, "win", "data.json") + result_path = tmp_path / "result.json" + bat_path = tmp_path / "puzzle2.bat" + + cmd = { + "module_directory_path": os.path.join(TESTS_DATA, "tasks"), + "task_set_path": task_path, + "data_path": data_path, + "result_path": str(result_path), + "execute_now": False, + "bat_file": str(bat_path), + "close_app": True, + "version": "2024", + } + + app = "maya" + run_process(app, **cmd) + assert bat_path.exists() + text = bat_path.read_text(encoding="utf-8", errors="ignore") + # バッチ内にPUZZLE_JOB_PATHとmaya.exe起動コマンドが含まれることを軽く検査 + assert "PUZZLE_JOB_PATH" in text diff --git a/tests/dcc/batch/test_batch_dcc.py b/tests/dcc/batch/test_batch_dcc.py new file mode 100644 index 0000000..75ae812 --- /dev/null +++ b/tests/dcc/batch/test_batch_dcc.py @@ -0,0 +1,106 @@ +import os +import time +import pprint +import pytest +from importlib import import_module as _imp_for_matrix + +# Defer puzzle2 imports inside functions to avoid static resolver warnings + +# DCC 実行を伴うため、明示的にゲート(1/true/yes/on のみ有効) +_RUN_DCC = os.getenv("PUZZLE_RUN_DCC_TESTS", "").strip().lower() in ("1", "true", "yes", "on") +if not _RUN_DCC: + pytest.skip( + "DCC tests are opt-in. Set PUZZLE_RUN_DCC_TESTS=1 to enable.", + allow_module_level=True, + ) +TESTS_DATA = os.path.join(os.path.dirname(__file__), "..", "..", "tests_data") + +def _jobs_dir(name: str) -> str: + return os.path.normpath(os.path.join(TESTS_DATA, "jobs_local", name)) + + +def _tasks_root() -> str: + return os.path.normpath(os.path.join(TESTS_DATA)) + + +def _wait_for_file(path: str, timeout: int = 300, interval: float = 5.0) -> bool: + """Wait until a file exists and is non-empty. + + Returns True when the condition is met within timeout; otherwise False. + """ + deadline = time.time() + timeout + while time.time() < deadline: + try: + if os.path.exists(path) and os.path.getsize(path) > 0: + return True + except Exception: + # ignore transient filesystem errors and retry + pass + time.sleep(interval) + return False + + +def _run_one(app: str, version: str, tmp_path, file_mode: bool = False): + from importlib import import_module as _imp + run_process = _imp("puzzle2.run_process").run_process + pz_config = _imp("puzzle2.pz_config") + command_data = { + "version": version, + "module_directory_path": _tasks_root(), + # isolate each run under a unique temporary directory + "job_directory": os.fspath(tmp_path / f"{app}_{version}"), + "close_app": True, + } + + if file_mode: + command_data["task_set_path"] = os.path.normpath(os.path.join(TESTS_DATA, "win", "task_set.yml")) + command_data["data_path"] = os.path.normpath(os.path.join(TESTS_DATA, "win", "data.json")) + else: + command_data["task_set"] = [ + {"step": "pre", "tasks": [{"module": "tasks.win.open_file"}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, + ] + command_data["data_set"] = { + "pre": {"open_path": "somewhere"}, + "main": [{"name": "nameA"}, {"name": "namessB"}], + } + + # ensure the temp job directory exists (run_process will also create if missing) + os.makedirs(command_data["job_directory"], exist_ok=True) + result_path = os.path.join(command_data["job_directory"], "results.json") + if os.path.exists(result_path): + os.remove(result_path) # clean up previous results + + command, job_directory = run_process(app, **command_data) + + if not command: + # App not installed or command couldn't be built + pytest.skip(f"DCC not available or command could not be built for {app} {version}") + + std_path = os.path.join(job_directory, "std.txt") + debug = {"result_path": result_path, "job_directory": job_directory} + if os.path.exists(std_path): + try: + with open(std_path, "r", encoding="utf-8", errors="ignore") as f: + debug["std_tail"] = f.read()[-1000:] + except Exception: + pass + pprint.pprint(debug) + # Wait for worker to produce results.json (useful when external process like Deadline writes it) + + if not _wait_for_file(result_path, timeout=int(os.environ.get("PUZZLE_RESULTS_TIMEOUT", 300)), interval=5.0): + pytest.fail( + f"results.json not found within timeout: {result_path}\n" + f"job_directory={job_directory}\n" + f"std_tail={debug.get('std_tail','')}" + ) + + _, data = pz_config.read(result_path) + return [l["return_code"] for l in data["results"]] + + +_matrix = _imp_for_matrix("tests.support.matrix") +@pytest.mark.parametrize("app,version", _matrix.CASES, ids=_matrix.IDS) +def test_batch_matrix(tmp_path, app, version): + rc = _run_one(app, version, tmp_path, file_mode=False) + assert rc == [0, 0, 0], f"job failed for {app} {version}" diff --git a/tests/dcc/deadline/test_submit.py b/tests/dcc/deadline/test_submit.py new file mode 100644 index 0000000..8fd7452 --- /dev/null +++ b/tests/dcc/deadline/test_submit.py @@ -0,0 +1,204 @@ +import os +import sys +import json +import uuid +import tempfile +import shutil +import time +import pytest + +# Opt-in gate: only run when PUZZLE_RUN_DCC_TESTS is one of 1/true/yes/on +_RUN_DCC = os.getenv("PUZZLE_RUN_DCC_TESTS", "").strip().lower() in ("1", "true", "yes", "on") +if not _RUN_DCC: + pytest.skip( + "Opt-in: requires Deadline client and repo plugin configured.", + allow_module_level=True, + ) + +# Defer imports of puzzle2 modules inside tests to avoid static resolver warnings + +def _resolve_deadline_command(): + cand = os.environ.get("DEADLINE_COMMAND") + if cand: + cand = cand.strip().strip('"') + cand = os.path.normpath(os.path.expandvars(os.path.expanduser(cand))) + if os.path.isfile(cand): + return cand + guess = os.path.join(cand, "deadlinecommand.exe") + if os.path.isfile(guess): + return guess + for name in ("deadlinecommand.exe", "deadlinecommand"): + path = shutil.which(name) + if path: + return path + program_files = os.environ.get("ProgramFiles", r"C:\\Program Files") + candidates = [ + os.path.join(program_files, "Thinkbox", "Deadline10", "bin", "deadlinecommand.exe"), + os.path.join(program_files, "Thinkbox", "Deadline", "bin", "deadlinecommand.exe"), + ] + for p in candidates: + if os.path.isfile(p): + return p + return None + + +def _wait_for_file(path: str, timeout: int = 300, interval: float = 5.0) -> bool: + """Wait until a file exists and is non-empty. + + Returns True when the condition is met within timeout; otherwise False. + """ + deadline = time.time() + timeout + while time.time() < deadline: + try: + if os.path.exists(path) and os.path.getsize(path) > 0: + return True + except Exception: + # ignore transient filesystem errors and retry + pass + time.sleep(interval) + return False + + +from importlib import import_module as _imp_for_matrix +_matrix = _imp_for_matrix("tests.support.matrix") +@pytest.mark.parametrize("app,version", _matrix.CASES, ids=_matrix.IDS) +def test_deadline_submit(tmp_path, app, version): + dl_cmd = _resolve_deadline_command() + if not dl_cmd: + pytest.skip("deadlinecommand not found") + + if app in ["maya", "motionbuilder"]: + return pytest.skip("the app requires GUI") + + # Build minimal job/plugin info files + job_info = tmp_path / f"job_info_{app}.job" + plugin_info = tmp_path / f"plugin_info_{app}.job" + + # Minimal Job Info (UTF-16LE) + job_info.write_text(f"Plugin=puzzle2\nName=Puzzle2.deadline.test_submit [{app}]\n", encoding="utf-16le") + + # Use local tests dir as module path; requires local Worker for true end-to-end + repo_root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) + tests_root = os.path.join(repo_root, "tests") + + # Prepare task/data JSON under temp shared dir + out_dir = tmp_path / f"shared_{app}" + out_dir.mkdir(parents=True, exist_ok=True) + task_path = out_dir / "task_set.json" + data_path = out_dir / "data.json" + result_path = out_dir / "results.json" + + task_set = [ + {"step": "pre", "tasks": [{"module": "tasks.win.open_file"}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, + ] + data_set = {"pre": {"open_path": None}, "main": [{"name": "A"}]} + + task_path.write_text(json.dumps({"info": {}, "data": task_set}, ensure_ascii=False, indent=2), encoding="utf-8") + data_path.write_text(json.dumps({"info": {}, "data": data_set}, ensure_ascii=False, indent=2), encoding="utf-8") + + module_path = tests_root.replace("\\", "/") + + # Write plugin info (UTF-16LE) + lines = [] + lines.append(f"App={app}\n") + lines.append(f"Version={version}\n") + # Add ModulePath (for tasks.*) and SysPath (extra sys.path entries) for the new plugin + lines.append(f"ModulePath={module_path}/tests_data\n") + lines.append(f"SysPath={module_path}\n") + lines.append(f"TaskPath={task_path.as_posix()}\n") + lines.append(f"DataPath={data_path.as_posix()}\n") + lines.append(f"ResultPath={result_path.as_posix()}\n") + plugin_info.write_text("".join(lines), encoding="utf-16le") + + # Submit; since this depends on external infra, just assert the command returns something + import subprocess + print(" ".join([dl_cmd, str(job_info), str(plugin_info)])) + proc = subprocess.run([dl_cmd, str(job_info), str(plugin_info)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, shell=False) + # We don't fail the test on non-zero; only verify execution path works and then wait for results + assert proc.stdout is not None + + # Wait for worker to produce results.json if a Worker picks the job + timeout = int(os.environ.get("PUZZLE_DEADLINE_TIMEOUT", os.environ.get("PUZZLE_RESULTS_TIMEOUT", 3000))) + print(timeout) + if not _wait_for_file(str(result_path), timeout=timeout, interval=5.0): + pytest.fail( + "results.json not found within timeout after Deadline submission\n" + f"deadlinecommand={dl_cmd}\n" + f"job_info={job_info}\n" + f"plugin_info={plugin_info}\n" + f"result_path={result_path}\n" + f"stdout_tail={proc.stdout[-1000:] if proc.stdout else ''}\n" + f"stderr_tail={proc.stderr[-1000:] if proc.stderr else ''}" + ) + else: + from importlib import import_module as _imp + pz_config = _imp("puzzle2.pz_config") + _, data = pz_config.read(result_path.as_posix()) + assert [l["return_code"] for l in data["results"]] == [0, 0] + + +@pytest.mark.parametrize("app,version", _matrix.CASES, ids=_matrix.IDS) +def test_submit_via_client(tmp_path, app, version): + """Same smoke test as above, but using the puzzle2 Deadline client API.""" + # Import client lazily to avoid static import resolution issues in linters + from importlib import import_module + + # Prepare minimal task/data under a temp shared directory + repo_root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) + tests_root = os.path.join(repo_root, "tests") + module_path = os.path.join(tests_root, "tests_data") + + out_dir = tmp_path / f"client_shared_{app}" + out_dir.mkdir(parents=True, exist_ok=True) + task_path = out_dir / "task_set.json" + data_path = out_dir / "data.json" + result_path = out_dir / "results.json" + + job_name = f"Puzzle2.deadline.test_submit_via_client [{app}]" + + task_set = [ + {"step": "pre", "tasks": [{"module": "tasks.win.open_file"}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, + ] + data_set = {"pre": {"open_path": None}, "main": [{"name": "A"}]} + + task_path.write_text(json.dumps({"info": {}, "data": task_set}, ensure_ascii=False, indent=2), encoding="utf-8") + data_path.write_text(json.dumps({"info": {}, "data": data_set}, ensure_ascii=False, indent=2), encoding="utf-8") + + # Submit via client; if Deadline is not installed, skip + try: + dl_submit = import_module("puzzle2.plugins.deadline.client").submit + res = dl_submit( + app=app, + job_name=job_name, + version=version, + module_path=module_path, + sys_path=tests_root, + task_path=task_path.as_posix(), + data_path=data_path.as_posix(), + result_path=result_path.as_posix(), + ) + except FileNotFoundError: + pytest.skip("deadlinecommand not found") + + # Ensure command executed + assert res.completed.stdout is not None + + # Wait for worker to produce results.json if a Worker picks the job + timeout = int(os.environ.get("PUZZLE_DEADLINE_TIMEOUT", os.environ.get("PUZZLE_RESULTS_TIMEOUT", 3000))) + if not _wait_for_file(str(result_path), timeout=timeout, interval=5.0): + pytest.fail( + "results.json not found within timeout after Deadline submission (client)\n" + f"job_info={res.job_info_path}\n" + f"plugin_info={res.plugin_info_path}\n" + f"result_path={result_path}\n" + f"stdout_tail={res.completed.stdout[-1000:] if res.completed.stdout else ''}\n" + f"stderr_tail={res.completed.stderr[-1000:] if res.completed.stderr else ''}" + ) + else: + from importlib import import_module as _imp + pz_config = _imp("puzzle2.pz_config") + _, data = pz_config.read(result_path.as_posix()) + assert [l["return_code"] for l in data["results"]] == [0, 0] + diff --git a/tests/dcc/houdini/README.md b/tests/dcc/houdini/README.md new file mode 100644 index 0000000..5ec47f5 --- /dev/null +++ b/tests/dcc/houdini/README.md @@ -0,0 +1,3 @@ +# Houdini DCC tests + +These are opt-in tests intended to run with `hython` installed on the machine. diff --git a/tests/dcc/maya/README.md b/tests/dcc/maya/README.md new file mode 100644 index 0000000..c9a193b --- /dev/null +++ b/tests/dcc/maya/README.md @@ -0,0 +1,3 @@ +# Maya DCC tests + +These are opt-in tests intended to run with `mayabatch` or `mayapy` installed on the machine. diff --git a/tests/dcc/pipe/README.md b/tests/dcc/pipe/README.md new file mode 100644 index 0000000..5faf256 --- /dev/null +++ b/tests/dcc/pipe/README.md @@ -0,0 +1,3 @@ +# Cross-DCC (Pipe) tests + +These are opt-in tests intended to run across multiple DCCs when installed. diff --git a/tests/dcc/pipe/test_pipe_dcc.py b/tests/dcc/pipe/test_pipe_dcc.py new file mode 100644 index 0000000..1209862 --- /dev/null +++ b/tests/dcc/pipe/test_pipe_dcc.py @@ -0,0 +1,86 @@ +import os +import pytest +from puzzle2.Puzzle import Puzzle + +# Opt-in gate: only run when PUZZLE_RUN_DCC_TESTS is one of 1/true/yes/on +_RUN_DCC = os.getenv("PUZZLE_RUN_DCC_TESTS", "").strip().lower() in ("1", "true", "yes", "on") +if not _RUN_DCC: + pytest.skip( + "Opt-in: requires Deadline client and repo plugin configured.", + allow_module_level=True, + ) + +TESTS_DIR = os.path.dirname(__file__) +PROJECT_ROOT = os.path.normpath(os.path.join(TESTS_DIR, "..", "..", "..")) +TESTS_DATA = os.path.normpath(os.path.join(PROJECT_ROOT, "tests", "tests_data")) + + +def test_pipe_simple(): + p = Puzzle("pipeTest", new=True) + + steps = [ + {"step": "pre", "tasks": [{"module": "tasks.win.open_file"}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, + {"step": "pipe", + "pipe": {"app": "mayapy", "version": "2024"}, + "sys_path": TESTS_DATA, + "tasks": [ + {"step": "pipe.pre", + "tasks": [ + {"module": "tasks.win.open_file", + "data_key_replace": {"open_path": "context.OpenFile.update_context_test"}} + ]} + ]}, + ] + + data = { + "pre": {"open_path": "somewhere"}, + "common": {"A": 123}, + "main": [{"name": "nameA"}, {"name": "nameB"}], + "pipe.pre": {"open_path": "A"}, + } + + p.play(steps, data) + print(p.logger.details.get_return_codes()) + assert p.logger.details.get_return_codes() == [0, 0, 0, 0] + + +def test_pipe_complex(): + p = Puzzle("pipeTest", new=True) + + steps = [ + {"step": "pre", "tasks": [{"module": "tasks.win.open_file"}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, + {"step": "pipe", + "pipe": {"app": "mayapy", "version": "2024"}, + "sys_path": TESTS_DATA, + "tasks": [ + {"step": "pipe.pre", + "tasks": [ + {"module": "tasks.win.open_file", + "data_key_replace": {"open_path": "context.OpenFile.update_context_test"}} + ]} + ]}, + {"step": "pipe2", + "pipe": {"app": "motionbuilder", "version": "2024"}, + "close_app": True, + "sys_path": TESTS_DATA, + "tasks": [ + {"step": "pipe.main", + "tasks": [ + {"module": "tasks.win.open_file"} + ]} + ]}, + ] + + data = { + "pre": {"open_path": "somewhere"}, + "common": {"A": 123}, + "main": [{"name": "nameA"}, {"name": "nameB"}], + "pipe.pre": {"open_path": "A"}, + "pipe.main": {"open_path": "B"}, + } + + p.play(steps, data) + print(p.logger.details.get_return_codes()) + assert p.logger.details.get_return_codes() == [0, 0, 0, 0, 0] diff --git a/tests/integration/test_batch_kicker_local.py b/tests/integration/test_batch_kicker_local.py new file mode 100644 index 0000000..6a66afd --- /dev/null +++ b/tests/integration/test_batch_kicker_local.py @@ -0,0 +1,73 @@ +import os +import json +import datetime + +def test_batch_kicker_runs_locally(tmp_path): + """Run batch_kicker.main in-process (no DCC) using tasks.win.* and verify results.json.""" + repo_root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) + src_dir = os.path.join(repo_root, "src") + tests_data_dir = os.path.join(repo_root, "tests", "tests_data") + # Ensure imports work inside the test + import sys + if src_dir not in [p.replace('\\', '/') for p in sys.path]: + sys.path.insert(0, src_dir) + if tests_data_dir not in [p.replace('\\', '/') for p in sys.path]: + sys.path.insert(0, tests_data_dir) + + import importlib + pz_config = importlib.import_module("puzzle2.pz_config") + batch_kicker = importlib.import_module("puzzle2.batch_kicker") + + # Job directory under tests_data/jobs_local + now = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + job_dir = os.path.join(tests_data_dir, "jobs_local", f"local_{now}") + os.makedirs(job_dir, exist_ok=True) + + # Simple task and data: use win tasks that don't require DCC + task_set = [ + {"step": "pre", "tasks": [{"name": "open", "module": "tasks.win.open_file"}]}, + {"step": "main", "tasks": [{"name": "export", "module": "tasks.win.export_file"}]}, + ] + data_set = { + "pre": {"open_path": None}, + "main": [{"name": "ItemA"}, {"name": "ItemB"}], + } + + # Write artifacts expected by batch_kicker + task_set_path = os.path.join(job_dir, "tasks.json") + data_path = os.path.join(job_dir, "data.json") + result_path = os.path.join(job_dir, "results.json") + pz_config.save(task_set_path, task_set) + pz_config.save(data_path, data_set) + + env = { + "app": "", # pure python + "puzzle_directory": src_dir.replace("\\", "/"), + "log_name": "puzzle", + "log_directory": job_dir.replace("\\", "/"), + "keys": "", + "script_path": os.path.join(src_dir, "puzzle2", "batch_kicker.py").replace("\\", "/"), + "task_set": True, + "data_set": True, + "module_directory_path": tests_data_dir.replace("\\", "/"), + "close_app": True, + "sys_path": "", + "result_path": result_path.replace("\\", "/"), + } + + config_path = os.path.join(job_dir, "config.json") + pz_config.save( + config_path, + {"env": env, "data_path": data_path.replace("\\", "/"), "task_set_path": task_set_path.replace("\\", "/"), "context_path": None}, + ) + + # Execute in-process + batch_kicker.main(config_path) + + assert os.path.exists(result_path), "results.json was not created" + with open(result_path, "r", encoding="utf8") as f: + result = json.load(f) + data = result.get("data", {}) + results = data.get("results", []) + assert len(results) >= 2, f"unexpected results length: {len(results)}" + assert all("return_code" in r for r in results) diff --git a/tests/integration/test_config_consistency.py b/tests/integration/test_config_consistency.py new file mode 100644 index 0000000..55b66b4 --- /dev/null +++ b/tests/integration/test_config_consistency.py @@ -0,0 +1,22 @@ +import os +import sys + +# Ensure src is importable +BASE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.normpath(os.path.join(BASE, "..", "..", "src")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +import pytest +_puzzle2 = pytest.importorskip("puzzle2") +from importlib import import_module +pz_config = import_module("puzzle2.pz_config") # noqa: E402 + + +def test_config_json_roundtrip(tmp_path): + p = tmp_path / "sample.json" + data = {"info": {"a": 1}, "data": {"x": 2}} + pz_config.save(str(p), data, "unit", "json") + + info, d = pz_config.read(str(p)) + assert d["data"]["x"] == 2 diff --git a/tests/integration/test_puzzle_core.py b/tests/integration/test_puzzle_core.py new file mode 100644 index 0000000..5fe2e42 --- /dev/null +++ b/tests/integration/test_puzzle_core.py @@ -0,0 +1,190 @@ +import os +import sys + +import pytest + +# conftest already adds src/ and tests/tests_data to sys.path +from importlib import import_module +Puzzle = import_module("puzzle2.Puzzle").Puzzle # noqa: E402 + + +def _add_path(*parts): + here = os.path.dirname(os.path.abspath(__file__)) + return os.path.normpath(os.path.join(here, "..", "tests_data", *parts)) + + +def test_simple(): + p = Puzzle("puzzle", new=True) + steps = [ + {"step": "pre", "tasks": [{"module": "tasks.win.open_file"}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, + ] + data = {"pre": {"open_path": "somewhere"}, "main": [{"name": "nameA"}, {"name": "nameB"}]} + p.play(steps, data) + assert p.logger.details.get_return_codes() == [0, 0, 0] + + +def test_task_failed_but_keep_running(): + p = Puzzle("puzzle", new=True) + steps = [ + {"step": "pre", "tasks": [{"module": "tasks.win.open_file"}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, + ] + # open_file needs open_path; give wrong key to cause 5, but continue + data = {"pre": {"path": "somewhere"}, "main": [{"name": "nameA"}, {"name": "nameB"}]} + p.play(steps, data) + assert p.logger.details.get_return_codes() == [5, 0, 0] + + +def test_task_failed_then_stopped(): + p = Puzzle("puzzle", new=True) + steps = [ + {"step": "pre", "tasks": [{"module": "tasks.win.open_file", "break_on_exceptions": True}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, + ] + data = {"pre": {"path": "somewhere"}, "main": [{"name": "nameA"}, {"name": "nameB"}]} + p.play(steps, data) + assert p.logger.details.get_return_codes() == [5] + + +def test_task_failed_stop_but_closure_is_executed(): + p = Puzzle("puzzle", new=True) + steps = [ + {"step": "pre", "tasks": [{"module": "tasks.win.open_file", "break_on_exceptions": True}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, + {"step": "closure", "tasks": [{"module": "tasks.win.revert"}]}, + ] + data = {"pre": {"path": "somewhere"}, "main": [{"name": "nameA"}, {"name": "nameB"}], "common": {"revert": {"a": 1}}} + p.play(steps, data) + assert p.logger.details.get_return_codes() == [5, 0] + + +def test_init_flow(): + p = Puzzle("puzzle", new=True) + steps = [ + {"step": "init", "tasks": [{"module": "tasks.win.open_file"}, {"module": "tasks.win.get_from_scene"}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, + ] + data = {"init": {"open_path": "somewhere"}} + p.play(steps, data) + assert p.logger.details.get_return_codes() == [0, 0, 0, 0, 0] + + +def test_update_data_and_use_it_in_other_task(): + p = Puzzle("puzzle", new=True) + steps = [ + {"step": "pre", "tasks": [{"module": "tasks.win.open_file", "break_on_exceptions": True}]}, + {"step": "main", "tasks": [ + {"module": "tasks.win.import_file"}, + {"module": "tasks.win.rename_namespace"}, + {"module": "tasks.win.export_file", "data_key_replace": {"name": "context.rename_namespace.new_name"}}, + ]}, + {"step": "post", "tasks": [ + {"module": "tasks.win.submit_to_sg", "data_key_replace": {"assets": "context.export_file.export_names"}}, + ]}, + ] + data = { + "pre": {"open_path": "somewhere"}, + "main": [{"name": "nameA", "path": "somewhere a"}, {"name": "nameB", "path": "somewhere b"}], + "common": {"shot_code": "ep000_s000_c000", "fps": 24, "start_frame": 101, "end_frame": 200}, + } + p.play(steps, data) + assert p.context["rename_namespace.new_name"] == "nameB_01" + assert p.context["export_file.export_names"] == ["nameA_01", "nameB_01"] + + +def test_data_defaults_and_override(): + p = Puzzle("puzzle", new=True) + steps = [{"step": "main", "tasks": [{"module": "tasks.win.add_specified_data", "data_defaults": {"add": 100}}]}] + data = {"main": {}} + p.play(steps, data) + assert p.context["add_specified_data.add"] == 100 + + data = {"main": {"add": 200}} + p.play(steps, data) + assert p.context["add_specified_data.add"] == 200 + + # override + p2 = Puzzle("puzzle2", new=True) + steps2 = [{"step": "main", "tasks": [{"module": "tasks.win.add_specified_data", "data_override": {"add": 100}}]}] + p2.play(steps2, {"main": {}}) + assert p2.context["add_specified_data.add"] == 100 + p2.play(steps2, {"main": {"add": 50}}) + assert p2.context["add_specified_data.add"] == 100 + + +def test_skip_flow(): + p = Puzzle("puzzle", new=True) + steps = [ + {"step": "pre", "tasks": [{"module": "tasks.win.get_from_scene"}]}, + {"step": "main", "tasks": [ + {"module": "tasks.win.import_file", "conditions": [{"test": ""}]}, + {"module": "tasks.win.rename_namespace", "conditions": [{"test": ""}]}, + {"module": "tasks.win.export_file", "conditions": [{"test": ""}], "data_key_replace": {"name": "context.rename_namespace.new_name"}}, + ]}, + {"step": "post", "tasks": [{"module": "tasks.win.submit_to_sg", "conditions": [{"test": ""}], "data_key_replace": {"assets": "context.export_file.export_names"}}]}, + ] + data = {} + p.play(steps, data) + names = [l["name"] for l in p.context["main"]] + assert ",".join(names) == "a,b,c" + + +def test_init_is_blank_then_break(): + p = Puzzle("puzzle", new=True) + steps = [ + {"step": "init", "tasks": [{"module": "tasks.win.open_file"}, {"module": "tasks.win.get_from_scene_empty", "break_on_exceptions": True}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, + ] + data = {"init": {"open_path": "somewhere"}} + p.play(steps, data) + assert p.logger.details.get_return_codes() == [0, 1] + + +def test_init_is_nothing(): + p = Puzzle("puzzle", new=True) + steps = [ + {"step": "init", "tasks": [{"module": "tasks.win.open_file"}, {"module": "tasks.win.get_from_scene_empty"}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, + {"step": "post", "tasks": [{"module": "tasks.win.export_file"}]}, + ] + data = {"init": {"open_path": "somewhere"}, "post": {"name": "somewhere"}} + p.play(steps, data) + assert p.logger.details.get_return_codes() == [0, 1, 0] + + +def test_init_is_nothing_and_break_safely(): + p = Puzzle("puzzle", new=True) + steps = [ + {"step": "init", "tasks": [{"module": "tasks.win.open_file"}, {"module": "tasks.win.get_from_scene_empty_break_on_conditions"}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}, + {"step": "post", "tasks": [{"module": "tasks.win.export_file"}]}, + ] + data = {"init": {"open_path": "somewhere"}, "post": {"name": "somewhere"}} + p.play(steps, data) + assert p.logger.details.get_return_codes() == [0, 0] + + +def test_conditions_skip_and_break_on_exceptions_true(): + p = Puzzle("puzzle", new=True) + steps = [{"step": "main", "tasks": [{"module": "tasks.win.rename_namespace", "conditions": [{"category": "chara"}], "break_on_exceptions": True}]}] + data = {"main": [{"category": "chara", "name": "charaA"}, {"category": "chara", "name": "charaB"}, {"categery": "bg", "name": "bgA"}, {"categery": "bg", "name": "bgB"}]} + p.play(steps, data) + assert p.logger.details.get_return_codes() == [0, 0, 2, 2] + + +def test_break_on_exceptions_is_true_and_error_occur(): + p = Puzzle("puzzle", new=True) + steps = [{"step": "main", "tasks": [{"module": "tasks.win.rename_namespace_with_error", "conditions": [{"categery": "bg"}], "break_on_exceptions": True}]}] + data = {"main": [{"category": "chara", "name": "charaA"}, {"category": "chara", "name": "charaB"}, {"categery": "bg", "name": "bgA"}, {"categery": "bg", "name": "bgB"}]} + p.play(steps, data) + assert p.logger.details.get_return_codes() == [2, 2, 4] + + +def test_add_location(): + p = Puzzle("puzzle", new=True) + add_path = _add_path("tasks", "win", "add_sys_path_test") + steps = [{"step": "main", "sys_path": add_path, "tasks": [{"module": "other_location"}]}] + data = {"main": {}} + p.play(steps, data) + assert p.logger.details.get_return_codes() == [0] diff --git a/tests/support/matrix.py b/tests/support/matrix.py new file mode 100644 index 0000000..9690d04 --- /dev/null +++ b/tests/support/matrix.py @@ -0,0 +1,75 @@ +# Re-export of centralized app/version matrix for tests +# Moved from src/puzzle2/tests_support/matrix.py to tests/support/matrix.py + +from __future__ import annotations + +import json +import os +from typing import Dict, List, Sequence, Tuple + +DEFAULT_MATRIX: Dict[str, List[str]] = { + "mayapy": ["2024"], + "mayabatch": ["2024"], + "mobupy": ["2024"], + "motionbuilder": ["2024"], + "houdini": ["20.5.654"], +} + + +def _parse_env_matrix() -> Dict[str, List[str]] | None: + # PUZZLE_TEST_MATRIX_JSON examples (JSON string): + # { + # "mayapy": ["2024", "2025"], + # "mayabatch": ["2024"], + # "mobupy": ["2024"], + # "motionbuilder": ["2024"], + # "houdini": ["20.5.654"] + # } + # Only the listed apps/versions will be used when this is set (no merge with defaults). + # PowerShell example: + # $env:PUZZLE_TEST_MATRIX_JSON = '{"mayapy":["2024"],"houdini":["20.5.654"]}' + raw_json = os.environ.get("PUZZLE_TEST_MATRIX_JSON") + if raw_json: + try: + data = json.loads(raw_json) + return {str(k): [str(vv) for vv in (v or [])] for k, v in data.items()} + except Exception: + pass + + raw_pairs = os.environ.get("PUZZLE_TEST_APPS") + if raw_pairs: + matrix: Dict[str, List[str]] = {} + for part in raw_pairs.split(";"): + part = part.strip() + if not part: + continue + if ":" in part: + app, ver = part.split(":", 1) + else: + app, ver = part, "" + app = app.strip() + ver = ver.strip() + if not app: + continue + matrix.setdefault(app, []) + if ver: + matrix[app].append(ver) + if matrix: + return matrix + return None + + +def build_cases(matrix: Dict[str, Sequence[str]] | None = None) -> Tuple[List[Tuple[str, str]], List[str]]: + src = _parse_env_matrix() or matrix or DEFAULT_MATRIX + cases: List[Tuple[str, str]] = [] + ids: List[str] = [] + for app, versions in src.items(): + for v in versions: + cases.append((app, v)) + ids.append(f"{app}-{v}") + return cases, ids + + +CASES, IDS = build_cases() + +__all__ = ["DEFAULT_MATRIX", "build_cases", "CASES", "IDS"] diff --git a/tests/unit/test_pzlog_scoped.py b/tests/unit/test_pzlog_scoped.py new file mode 100644 index 0000000..9130431 --- /dev/null +++ b/tests/unit/test_pzlog_scoped.py @@ -0,0 +1,258 @@ +import os +import sys +import json +import tempfile + +import pytest + +sys.dont_write_bytecode = True +os.environ["PYTHONDONTWRITEBYTECODE"] = "1" + +# Ensure src is importable +BASE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.normpath(os.path.join(BASE, "..", "..", "..", "src")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +_puzzle2 = pytest.importorskip("puzzle2") +from importlib import import_module +PzLog = import_module("puzzle2.PzLog").PzLog # noqa: E402 +_pz_config = import_module("puzzle2.pz_config") # noqa: E402 + + +def test_pzlog_uses_json_template_when_yaml_unavailable(tmp_path, monkeypatch): + # Force YAML unavailable switch YAML_AVAILABLE to False + monkeypatch.setattr(_pz_config, "YAML_AVAILABLE", False, raising=False) + + log_dir = tmp_path / "logs" + log = PzLog("unittest", new=True, log_directory=str(log_dir)) + # Write something + log.logger.info("hello") + + # Config should be created as JSON and log file should exist + cfg = log.config_path + assert cfg.endswith(".json") + assert os.path.isfile(cfg) + assert os.path.isfile(log.log_path) + + +def test_pzlog_no_global_side_effects(tmp_path): + log_dir = tmp_path / "logs" + import logging + root_before = logging.getLogger(__name__).handlers[:] + + log = PzLog("scoped", new=True, log_directory=str(log_dir)) + + root_mid = logging.getLogger(__name__).handlers[:] + + # Emitting logs shouldn't alter other loggers' handlers + log.logger.info("hello") + root_after = logging.getLogger(__name__).handlers[:] + assert root_before == root_mid + assert root_before == root_after + + +def test_pzlog_propagate_false(tmp_path): + log_dir = tmp_path / "logs" + log = PzLog("prop", new=True, log_directory=str(log_dir)) + assert log.logger.propagate is False + + +# ---- Ported and adapted from legacy tests/src/win/_test_PzLog.py ---- + +def _get_handler(logger, name): + for h in logger.handlers: + if getattr(h, "name", None) == name: + return h + return None + + +def test_pzlog_reset_template_uses_default_and_stream_debug(tmp_path): + import logging + log_dir = tmp_path / "logs" + log = PzLog("test_default", reset_template=True, new=True, log_directory=str(log_dir)) + + # Default template file name can be YAML or JSON depending on environment + base = os.path.basename(getattr(log, "base_template_path", "")) + assert base in ("log.template.yml", "log.template.json") + + stream = _get_handler(log.logger, "stream_handler") + assert stream is not None + # Validate level matches template's configured level + _, cfg = _pz_config.read(log.base_template_path) + tmpl_level = cfg.get("handlers", {}).get("stream_handler", {}).get("level", "DEBUG") + expected = getattr(logging, str(tmpl_level).upper(), logging.DEBUG) + assert stream.level == expected + + +def test_pzlog_use_existing_template_override_level(tmp_path): + import logging + log_dir = tmp_path / "logs" + logA = PzLog("test2", log_directory=str(log_dir)) + + # Modify EXISTING CONFIG (not global template): set stream level to CRITICAL + info, cfg = _pz_config.read(logA.config_path) + # Ensure nested keys exist before update + handlers = cfg.setdefault("handlers", {}) + handlers.setdefault("stream_handler", {}) + handlers["stream_handler"]["level"] = "CRITICAL" + _pz_config.save(logA.config_path, cfg, {}) + + # New logger with same name should pick updated template + logB = PzLog("test2", new=True, reset_template=False, log_directory=str(log_dir)) + + base = os.path.basename(getattr(logB, "base_template_path", "")) + # base_template_path points to the existing config for this name + assert os.path.basename(logB.base_template_path).startswith("test2") + + streamA = _get_handler(logA.logger, "stream_handler") + streamB = _get_handler(logB.logger, "stream_handler") + assert streamA is not None and streamB is not None + assert streamA.level == logging.CRITICAL + assert streamB.level == logging.CRITICAL + + +def test_pzlog_add_date_to_log_name_keeps_default_template(tmp_path): + import logging + log_dir = tmp_path / "logs" + _ = PzLog("test3", add_date_to_log_name=True, log_directory=str(log_dir)) + logB = PzLog("test3", new=True, reset_template=False, add_date_to_log_name=True, log_directory=str(log_dir)) + + base = os.path.basename(getattr(logB, "base_template_path", "")) + # When date is embedded in log name, default template is used + assert base in ("log.template.yml", "log.template.json") + + streamB = _get_handler(logB.logger, "stream_handler") + assert streamB is not None + # Validate level matches template's configured level + _, cfg = _pz_config.read(logB.base_template_path) + tmpl_level = cfg.get("handlers", {}).get("stream_handler", {}).get("level", "DEBUG") + expected = getattr(logging, str(tmpl_level).upper(), logging.DEBUG) + assert streamB.level == expected + + +def test_pzlog_log_filename_contains_date_and_name(tmp_path): + import datetime + log_dir = tmp_path / "logs" + log = PzLog("test1", add_date_to_log_name=True, log_directory=str(log_dir)) + + today = datetime.datetime.now().strftime("%Y%m%d") + file_h = _get_handler(log.logger, "file_handler") + # file_handler may not exist in some minimal configs; skip if absent + if file_h is None: + pytest.skip("file_handler not configured; skip filename assertion") + base = os.path.basename(getattr(file_h, "baseFilename", "")) + assert base == f"{today}_test1.log" + + +def test_pzlog_log_filename_contains_date_name_user(tmp_path): + import datetime + log_dir = tmp_path / "logs" + log = PzLog("test4", add_date_to_log_name=True, user_name="name", log_directory=str(log_dir)) + + today = datetime.datetime.now().strftime("%Y%m%d") + file_h = _get_handler(log.logger, "file_handler") + if file_h is None: + pytest.skip("file_handler not configured; skip filename assertion") + base = os.path.basename(getattr(file_h, "baseFilename", "")) + assert base == f"{today}_test4_name.log" + + +def test_pzlog_update_level(tmp_path): + import logging + log_dir = tmp_path / "logs" + log = PzLog("test4", file_handler_level="CRITICAL", stream_handler_level="CRITICAL", log_directory=str(log_dir)) + for h in log.logger.handlers: + assert h.level == logging.CRITICAL + + +def test_pzlog_details_api(tmp_path): + log_dir = tmp_path / "logs" + log = PzLog("test5", log_directory=str(log_dir)) + logger = log.logger + + logger.details.set_name("task_name1") + name1 = logger.details.name + + logger.details.add_detail("test1") + logger.details.add_detail("test2") + logger.details.add_detail("test3") + logger.details.set_header(0, "first task") + + logger.details.set_name("task_name2") + name2 = logger.details.name + logger.details.add_detail("test4") + logger.details.add_detail("test5") + logger.details.add_detail("test6") + logger.details.set_header(1, "secound task") + + d1 = logger.details.get_details(name1) + d2 = logger.details.get_details(name2) + assert d1["details"] == ["test1", "test2", "test3"] + assert d2["details"] == ["test4", "test5", "test6"] + + assert logger.details.get_return_codes() == [0, 1] + + assert logger.details.get_all() == [ + {"return_code": 0, "header": "first task", "details": ["test1", "test2", "test3"], "meta_data": {}}, + {"return_code": 1, "header": "secound task", "details": ["test4", "test5", "test6"], "meta_data": {}} + ] + + logger.details.clear() + assert logger.details.get_header() == [] + assert logger.details.get_all() == [] + + +def test_pzlog_namespace_flag(tmp_path): + log_dir = tmp_path / "logs" + # Default: namespace True -> name is prefixed with 'puzzle.' + log_ns = PzLog("ns_test", new=True, log_directory=str(log_dir)) + assert log_ns.logger.name == "puzzle.ns_test" + + # namespace False -> no prefix + log_raw = PzLog("ns_test2", new=True, log_directory=str(log_dir), namespace=False) + assert log_raw.logger.name == "ns_test2" + + +def test_pzlog_change_handler_levels_override(tmp_path): + import logging + log_dir = tmp_path / "logs" + log = PzLog( + "levels", + new=True, + log_directory=str(log_dir), + stream_handler_level="ERROR", + file_handler_level="WARNING", + ) + sh = _get_handler(log.logger, "stream_handler") + fh = _get_handler(log.logger, "file_handler") + # Handlers may be absent depending on template; skip if missing + if sh is not None: + assert sh.level == logging.ERROR + if fh is not None: + assert fh.level == logging.WARNING + +def test_pzlog_details_add_data_and_set(tmp_path): + log_dir = tmp_path / "logs" + log = PzLog("details_add", new=True, log_directory=str(log_dir)) + det = log.logger.details + + # add_data + det.add_data("0 taskA", {"return_code": 0, "header": "A", "details": ["a1", "a2"], "meta_data": {"required": {"x": 1}, "execution_time": 0.1}}, location={"p": 1}) + # add_data_set + headers = ["1 taskB", "2 taskC"] + results = [ + {"return_code": 1, "header": "B", "details": ["b1"], "meta_data": {"required": {"y": 2}, "execution_time": 0.2}}, + {"return_code": 2, "header": "C", "details": ["c1", "c2"], "meta_data": {"required": {"z": 3}, "execution_time": 0.3}}, + ] + det.add_data_set(headers, results, location={"q": 9}) + + all_ = det.get_all() + # Three entries added + assert len(all_) == 3 + # First entry details + assert all_[0]["header"] == "A" + assert all_[0]["return_code"] == 0 + assert all_[0]["details"] == ["a1", "a2"] + # Return codes order + assert det.get_return_codes() == [0, 1, 2] diff --git a/tests/unit/test_pztask_unit.py b/tests/unit/test_pztask_unit.py new file mode 100644 index 0000000..0a70dbd --- /dev/null +++ b/tests/unit/test_pztask_unit.py @@ -0,0 +1,70 @@ +import os +import sys + +import pytest + +# Ensure src is importable (handled by tests/conftest.py), and add tests_data to sys.path for tasks.* +BASE = os.path.dirname(os.path.abspath(__file__)) +TESTS_DATA = os.path.normpath(os.path.join(BASE, "..", "tests_data")) +if TESTS_DATA not in sys.path: + sys.path.insert(0, TESTS_DATA) + +_puzzle2 = pytest.importorskip("puzzle2") +from importlib import import_module +PzTask = import_module("puzzle2.PzTask").PzTask # noqa: E402 +mock = import_module("tasks.win.mock") # noqa: E402 + + +def test_key_required(): + data = {} + pz_task = PzTask(module=mock, data=data) + response = pz_task.execute() + assert response["return_code"] == 5 + + data = {"name": "nameA"} + pz_task = PzTask(module=mock, data=data) + response = pz_task.execute() + assert response["return_code"] == 0 + + +def test_data_key_replace(): + data = {"new_name": "nameB"} + task = {"data_key_replace": {"name": "new_name"}} + + pz_task = PzTask(module=mock, task=task, data=data) + assert pz_task.data["name"] == data["new_name"] + + +def test_data_key_replace_from_other_task(): + data = {"name": "nameA"} + task = {"data_key_replace": {"name": "context.new_name"}} + + context = {"new_name": "nameB"} + pz_task = PzTask(module=mock, task=task, data=data, context=context) + assert pz_task.data["name"] == context["new_name"] + + +def test_conditions(): + data = {"name": "nameA", "category": "ch"} + task = {"conditions": [{"category": "ch"}]} + + pz_task = PzTask(module=mock, task=task, data=data) + assert pz_task.return_code == 0 + + data = {"name": "nameA", "category": "prop"} + task = {"conditions": [{"category": "ch"}]} + + pz_task = PzTask(module=mock, task=task, data=data) + assert pz_task.return_code == 2 + + data = {"name": "nameA", "category": "ch"} + task = {"conditions": [{"category": "ch", "name": "nameA"}]} + + pz_task = PzTask(module=mock, task=task, data=data) + assert pz_task.return_code == 0 + + data = {"name": "nameA", "category": "ch"} + task = {"conditions": [{"category": "ch", "name": "nameB"}]} + + pz_task = PzTask(module=mock, task=task, data=data) + assert pz_task.return_code == 2 diff --git a/tests/unit/test_run_process_artifacts.py b/tests/unit/test_run_process_artifacts.py new file mode 100644 index 0000000..8732c63 --- /dev/null +++ b/tests/unit/test_run_process_artifacts.py @@ -0,0 +1,74 @@ +import os +import sys +import json +import tempfile + +import pytest + +sys.dont_write_bytecode = True +os.environ["PYTHONDONTWRITEBYTECODE"] = "1" + +# Ensure src is importable +BASE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.normpath(os.path.join(BASE, "..", "..", "..", "src")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +_puzzle2 = pytest.importorskip("puzzle2") +from importlib import import_module +pz_config = import_module("puzzle2.pz_config") # noqa: E402 +run_process = import_module("puzzle2.run_process").run_process # noqa: E402 + + +def test_run_process_saves_job_files_without_dcc(monkeypatch, tmp_path): + # Prepare fake addon for a dummy DCC so we don't launch real apps + class DummyAddon: + @staticmethod + def get_command(**kwargs): + # Return a shell that exits with 0 + if os.name == "nt": + return ["cmd.exe", "/C", "exit", "0"] + return ["/bin/sh", "-lc", "exit 0"] + + @staticmethod + def add_env(**kwargs): + return {} + + # Monkeypatch importlib to return DummyAddon for a specific app + import importlib + + def fake_import(name, *a, **k): + if name.endswith("puzzle2.addons.dummy.integration"): + return DummyAddon + return real_import(name, *a, **k) + + real_import = importlib.import_module + monkeypatch.setattr(importlib, "import_module", fake_import) + + # Create minimal inputs + job_dir = tmp_path / "job" + task_set = [{"step": "pre", "tasks": [{"module": "tasks.win.open_file"}]}, + {"step": "main", "tasks": [{"module": "tasks.win.export_file"}]}] + data_set = {"pre": {"open_path": "x"}, "main": [{"name": "A"}]} + + command, job_directory = run_process( + "dummy", + task_set=task_set, + data_set=data_set, + job_directory=str(job_dir), + ) + + assert command, "Command should be returned" + assert os.path.isdir(job_directory) + + config_path = os.path.join(job_directory, "config.json") + tasks_path = os.path.join(job_directory, "tasks.json") + data_path = os.path.join(job_directory, "data.json") + + assert os.path.isfile(config_path) + assert os.path.isfile(tasks_path) + assert os.path.isfile(data_path) + + # Validate the config shape + info, cfg = pz_config.read(config_path) + assert "env" in cfg and "data_path" in cfg and "task_set_path" in cfg From 3307ba1dfe95fb8fe3d8e82e6c39bca4aa48fe5f Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 23:09:36 +0900 Subject: [PATCH 10/14] update python versions. finish supporting 2.x --- .github/workflows/test.yml | 4 ++-- .gitignore | 6 ++++++ setup.cfg | 8 +++++--- src/puzzle2/task.template | 31 +++++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 src/puzzle2/task.template diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5af87e..248bf60 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: os: [windows-latest] - python-version: ["2.7", "3.7", "3.9"] + python-version: ["3.7", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 @@ -28,4 +28,4 @@ jobs: env: PYTHONPATH: ${{ github.workspace }}/src run: | - python -m unittest discover -s ./tests/src/win \ No newline at end of file + python -m unittest discover -s ./tests \ No newline at end of file diff --git a/.gitignore b/.gitignore index 174618e..519de12 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ pipenv/ src/puzzle.egg-info/ tests/jobs/ tests/_old/ +tests/tests_data/jobs/* +tests/tests_data/jobs_local/* +src/puzzle2/plugins/deadline_old/* +docs/* +src/puzzle2.egg-info/* +.coverage diff --git a/setup.cfg b/setup.cfg index 33f78d9..254a3bb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,18 +3,20 @@ name = puzzle2 description = simple job framework author = Goh.Hattori, Narimitu.Ozaki license = MIT -version = 1.0.5 +version = 2.0.0 platform = linux, win32 classifiers = - Programming Language :: Python :: 2.7 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 [options] packages = puzzle2 install_requires = pyyaml -python_requires = >=2.7 +python_requires = >=3.7 package_dir = =src diff --git a/src/puzzle2/task.template b/src/puzzle2/task.template new file mode 100644 index 0000000..6a35f6e --- /dev/null +++ b/src/puzzle2/task.template @@ -0,0 +1,31 @@ + + + +# RETURN_SUCCESS = 0 +# RETURN_ERROR = 1 +# RETURN_SKIPPED = 2 +# RETURN_TASK_STOPPED = 3 +# RETURN_PIECE_NOT_FOUND = 4 + + +from puzzle2.PzLog import PzLog + +TASK_NAME = "add_namespace" +DATA_KEY_REQUIRED = [] + +def main(event={}, context={}): + + + data = event.get("data", {}) + + logger = context.get("logger") + if not logger: + logger = PzLog().logger + + return_code = 0 + logger.details.add_detail("get mira ragdoll controller: {}".format(controller)) + logger.details.set_header(return_code, header) + + + return {"return_code": return_code} + From 063b4e539fb63344161f101971a02ceedcb9744a Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 23:19:55 +0900 Subject: [PATCH 11/14] update README --- README.md | 50 ++++++++++++++++- src/puzzle2/PzLog.py | 2 +- src/puzzle2/tests_support/matrix.py | 83 ----------------------------- tests/dcc/batch/test_bat_dcc.py | 2 +- 4 files changed, 50 insertions(+), 87 deletions(-) delete mode 100644 src/puzzle2/tests_support/matrix.py diff --git a/README.md b/README.md index 79c443a..2d7c505 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,48 @@ -document -https://hat27.github.io/puzzle2/ +Puzzle2 + +Puzzle2 is a lightweight task runner/orchestrator for DCC applications (Maya, MotionBuilder, Houdini, etc.). It standardizes batch execution, logging, environment handoff, and integrates with Deadline. + +Docs +- Website: https://hat27.github.io/puzzle2/ + +Quick install +- From source (editable): + - pip install -e . + +Core pieces +- puzzle2.Puzzle: Orchestrates steps and tasks with structured results. +- puzzle2.run_process: Resolves and launches DCC commands in batch/headless mode and writes artifacts under a job directory. +- puzzle2.PzLog: Scoped logger with JSON/YAML template support, handler-level control, and a Details API for structured results. +- puzzle2.plugins.deadline: Thin client for building Job/Plugin info and submitting via deadlinecommand. + +Running tests (opt-in DCC/Deadline) +- DCC and Deadline tests are opt-in and gated by an environment variable. Only the following values enable them: 1, true, yes, on (case-insensitive). Any other value or unset disables them. + - Example (PowerShell): + - $env:PUZZLE_RUN_DCC_TESTS = "1" +- You can control which app/version pairs are used via environment variables: + - PUZZLE_TEST_MATRIX_JSON: JSON string overriding the entire matrix + - Example: + - $env:PUZZLE_TEST_MATRIX_JSON = '{"mayapy":["2024"],"houdini":["20.5.654"]}' + - PUZZLE_TEST_APPS: Semicolon-separated pairs app:version (no spaces) + - Example: + - $env:PUZZLE_TEST_APPS = "mayapy:2024;houdini:20.5.654" + +Deadline client usage +- Minimal example to submit a job that executes tests/tasks on a Worker: +- Python: + - from puzzle2.plugins.deadline import client + - res = client.submit( + app="mayapy", + job_name="Puzzle2 Example", + version="2024", + module_path="", + sys_path="", + task_path="", + data_path="", + result_path="", + ) + - print(res.completed.stdout) + +Notes +- CI should keep PUZZLE_RUN_DCC_TESTS disabled by default to avoid external dependency on installed DCCs and a Deadline farm. Opt-in per job when infrastructure is available. +- PzLog selects a YAML or JSON template automatically depending on availability; handlers are scoped to avoid global logging side effects. diff --git a/src/puzzle2/PzLog.py b/src/puzzle2/PzLog.py index 3ef6f63..ddb84f5 100644 --- a/src/puzzle2/PzLog.py +++ b/src/puzzle2/PzLog.py @@ -127,7 +127,7 @@ def clear(self): self.name = "" self.index = 0 -# TODO: 将来的にPzLoggerとPzLogを統合する事を検討 +# TODO: Consider merging PzLogger and PzLog in the future class PzLogger(logging.Logger): diff --git a/src/puzzle2/tests_support/matrix.py b/src/puzzle2/tests_support/matrix.py deleted file mode 100644 index a4019c9..0000000 --- a/src/puzzle2/tests_support/matrix.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Centralized app/version matrix for tests. - -Allows one place to manage which DCC apps and versions are exercised by tests. -Supports simple overrides via environment variables: - -- PUZZLE_TEST_APPS: semicolon-separated pairs like - mayapy:2024;mayabatch:2024;mobupy:2024;houdini:20.5.654 - -- PUZZLE_TEST_MATRIX_JSON: JSON string mapping app->list of versions, e.g. - {"mayapy":["2024"], "houdini":["20.5.654"]} - -If both are provided, PUZZLE_TEST_MATRIX_JSON takes precedence. -""" - -from __future__ import annotations - -import json -import os -from typing import Dict, List, Sequence, Tuple - - -DEFAULT_MATRIX: Dict[str, List[str]] = { - "mayapy": ["2024"], - "mayabatch": ["2024"], - "mobupy": ["2024"], - "motionbuilder": ["2024"], - "houdini": ["20.5.654"], -} - - -def _parse_env_matrix() -> Dict[str, List[str]] | None: - raw_json = os.environ.get("PUZZLE_TEST_MATRIX_JSON") - if raw_json: - try: - data = json.loads(raw_json) - # Normalize to list of strings - return {str(k): [str(vv) for vv in (v or [])] for k, v in data.items()} - except Exception: - pass - - raw_pairs = os.environ.get("PUZZLE_TEST_APPS") - if raw_pairs: - matrix: Dict[str, List[str]] = {} - for part in raw_pairs.split(";"): - part = part.strip() - if not part: - continue - if ":" in part: - app, ver = part.split(":", 1) - else: - app, ver = part, "" - app = app.strip() - ver = ver.strip() - if not app: - continue - matrix.setdefault(app, []) - if ver: - matrix[app].append(ver) - if matrix: - return matrix - return None - - -def build_cases(matrix: Dict[str, Sequence[str]] | None = None) -> Tuple[List[Tuple[str, str]], List[str]]: - """Build (app, version) pairs and pytest ids from a matrix mapping. - - Returns: - CASES: list of (app, version) - IDS: list of readable ids like "mayapy-2024" - """ - src = _parse_env_matrix() or matrix or DEFAULT_MATRIX - cases: List[Tuple[str, str]] = [] - ids: List[str] = [] - for app, versions in src.items(): - for v in versions: - cases.append((app, v)) - ids.append(f"{app}-{v}") - return cases, ids - - -CASES, IDS = build_cases() - -__all__ = ["DEFAULT_MATRIX", "build_cases", "CASES", "IDS"] diff --git a/tests/dcc/batch/test_bat_dcc.py b/tests/dcc/batch/test_bat_dcc.py index a39001a..bb0cfbf 100644 --- a/tests/dcc/batch/test_bat_dcc.py +++ b/tests/dcc/batch/test_bat_dcc.py @@ -38,5 +38,5 @@ def test_generate_bat_for_maya_2024(tmp_path): run_process(app, **cmd) assert bat_path.exists() text = bat_path.read_text(encoding="utf-8", errors="ignore") - # バッチ内にPUZZLE_JOB_PATHとmaya.exe起動コマンドが含まれることを軽く検査 + # Basic check: the batch should contain PUZZLE_JOB_PATH and a maya.exe launch command assert "PUZZLE_JOB_PATH" in text From 72446bcfc76424912c00e95bcf334690d171002f Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 23:45:55 +0900 Subject: [PATCH 12/14] update unittest to pytest --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 248bf60..ffcd38d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,8 +24,9 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt + pip install pytest - name: Run unittests env: PYTHONPATH: ${{ github.workspace }}/src run: | - python -m unittest discover -s ./tests \ No newline at end of file + python -m pytest /tests \ No newline at end of file From 5914f959a8f7d43f9b77acad11978c893fe24ecd Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 23:47:31 +0900 Subject: [PATCH 13/14] fix test --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ffcd38d..6b0134a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,4 +29,4 @@ jobs: env: PYTHONPATH: ${{ github.workspace }}/src run: | - python -m pytest /tests \ No newline at end of file + python -m pytest ./tests \ No newline at end of file From a4b4d1103edf8404e76694c71bdec7b05b7a0b1d Mon Sep 17 00:00:00 2001 From: hat27 Date: Sun, 17 Aug 2025 23:49:25 +0900 Subject: [PATCH 14/14] add option to pytest --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b0134a..82c7fd5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,4 +29,4 @@ jobs: env: PYTHONPATH: ${{ github.workspace }}/src run: | - python -m pytest ./tests \ No newline at end of file + python -m pytest ./tests -v \ No newline at end of file