├── CLAUDE.md ├── .python-version ├── docs ├── logo.png └── description.png ├── main.py ├── pyhunt ├── __init__.py ├── context.py ├── colors.py ├── config.py ├── utils.py ├── cli.py ├── helpers.py ├── console.py ├── decorator.py └── logger.py ├── .vscode └── settings.json ├── examples ├── project_example │ ├── task.py │ ├── main.py │ └── manager.py ├── basic_example.py ├── class_example.py ├── outputs │ ├── basic_example.txt │ ├── class_example.txt │ ├── error_example.txt │ ├── async_example.txt │ ├── project_example.txt │ ├── nested_example.txt │ └── type_example.txt ├── error_example.py ├── nested_example.py ├── async_example.py └── type_example.py ├── pyproject.toml ├── LICENSE ├── test ├── prepare.py ├── test_examples.py └── cli │ └── test_cli_options.py ├── .github └── workflows │ └── release.yml ├── README_KR.md ├── .gitignore ├── AGENTS.md ├── README.md └── uv.lock /CLAUDE.md: -------------------------------------------------------------------------------- 1 | @AGENTS.md -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EasyDevv/pyhunt/HEAD/docs/logo.png -------------------------------------------------------------------------------- /docs/description.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EasyDevv/pyhunt/HEAD/docs/description.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | print("Hello from pyhunt!") 3 | 4 | 5 | if __name__ == "__main__": 6 | main() 7 | -------------------------------------------------------------------------------- /pyhunt/__init__.py: -------------------------------------------------------------------------------- 1 | from .decorator import trace 2 | from . import logger 3 | 4 | __all__ = ["trace", "logger"] 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "test" 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } -------------------------------------------------------------------------------- /examples/project_example/task.py: -------------------------------------------------------------------------------- 1 | from pyhunt import trace 2 | 3 | 4 | @trace 5 | class Task: 6 | def __init__(self, name): 7 | self.name = name 8 | self.completed = False 9 | 10 | async def complete(self): 11 | self.completed = True 12 | -------------------------------------------------------------------------------- /examples/basic_example.py: -------------------------------------------------------------------------------- 1 | from pyhunt import trace, logger 2 | 3 | 4 | @trace 5 | def sample_function(param1, param2): 6 | logger.info(f"Starting sample_function with params: {param1}, {param2}") 7 | result = param1 + param2 8 | 9 | return result 10 | 11 | 12 | if __name__ == "__main__": 13 | result = sample_function(5, 10) 14 | -------------------------------------------------------------------------------- /examples/project_example/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pyhunt import trace 3 | from manager import TaskManager 4 | 5 | 6 | @trace 7 | async def main(): 8 | manager = TaskManager() 9 | await manager.add_task("Write documentation") 10 | await manager.add_task("Implement feature X") 11 | await manager.complete_task("Write documentation") 12 | await manager.complete_task("Nonexistent task") 13 | 14 | 15 | if __name__ == "__main__": 16 | asyncio.run(main()) 17 | -------------------------------------------------------------------------------- /pyhunt/context.py: -------------------------------------------------------------------------------- 1 | import contextvars 2 | from typing import Optional 3 | 4 | # Stores the name or identifier (string) of the function running in the current context. 5 | current_function_context: contextvars.ContextVar[Optional[str]] = ( 6 | contextvars.ContextVar( 7 | "current_function_context", 8 | default=None, 9 | ) 10 | ) 11 | 12 | # Tracks the depth of the call stack (an integer) within the current context. 13 | call_depth: contextvars.ContextVar[int] = contextvars.ContextVar( 14 | "call_depth", 15 | default=0, 16 | ) 17 | -------------------------------------------------------------------------------- /examples/class_example.py: -------------------------------------------------------------------------------- 1 | from pyhunt import trace, logger 2 | 3 | 4 | @trace 5 | class Calculator: 6 | def add(self, a, b): 7 | logger.info(f"Adding {a} + {b}") 8 | return a + b 9 | 10 | def multiply(self, a, b): 11 | logger.info(f"Multiplying {a} * {b}") 12 | return a * b 13 | 14 | 15 | @trace 16 | def main(): 17 | calc = Calculator() 18 | sum_result = calc.add(3, 4) 19 | product_result = calc.multiply(3, 4) 20 | 21 | logger.info(f"Sum result: {sum_result}") 22 | logger.info(f"Product result: {product_result}") 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | -------------------------------------------------------------------------------- /examples/outputs/basic_example.txt: -------------------------------------------------------------------------------- 1 | ├─▶ 1 🟢 Entry sample_function | examples/basic_example.py:5 2 | │  { 3 | │   "param1": 5, 4 | │   "param2": 10 5 | │  } 6 | │  1 INFO Starting sample_function with params: 5, 10 7 | ├── 1 🔳 Exit sample_function 8 | -------------------------------------------------------------------------------- /examples/error_example.py: -------------------------------------------------------------------------------- 1 | from pyhunt import trace, logger 2 | 3 | 4 | @trace 5 | def level_three(value: int): 6 | logger.info("Inside level_three, about to raise TypeError.") 7 | 8 | # This will raise a TypeError, string + int 9 | try: 10 | result = "number: " + value 11 | return result 12 | except TypeError as e: 13 | raise TypeError(e) 14 | 15 | 16 | @trace 17 | def level_two(value): 18 | logger.info("Inside level_two, calling level_three.") 19 | level_three(value - 1) 20 | 21 | 22 | @trace 23 | def level_one(value): 24 | logger.info("Inside level_one, calling level_two.") 25 | level_two(value - 1) 26 | 27 | 28 | if __name__ == "__main__": 29 | int_value = 5 30 | level_one(int_value) 31 | -------------------------------------------------------------------------------- /examples/project_example/manager.py: -------------------------------------------------------------------------------- 1 | from pyhunt import trace, logger 2 | from task import Task 3 | 4 | 5 | @trace 6 | class TaskManager: 7 | def __init__(self): 8 | self.tasks = [] 9 | 10 | async def add_task(self, name): 11 | logger.info(f"Adding task '{name}'.") 12 | task = Task(name) 13 | self.tasks.append(task) 14 | return task 15 | 16 | async def complete_task(self, name): 17 | for task in self.tasks: 18 | if task.name == name and not task.completed: 19 | await task.complete() 20 | logger.info(f"Task '{name}' completed.") 21 | return True 22 | 23 | logger.warning(f"Task '{name}' not found or already completed.") 24 | return False 25 | -------------------------------------------------------------------------------- /pyhunt/colors.py: -------------------------------------------------------------------------------- 1 | DEPTH_COLORS = [ 2 | "#b9e97c", 3 | "#f0e6a8", 4 | "#fdbb6f", 5 | "#f58fa8", 6 | "#99d4f0", 7 | "#c1a6ff", 8 | "#f58f8f", 9 | "#f0e6a8", 10 | "#b9e97c", 11 | "#99d4f0", 12 | ] 13 | 14 | 15 | def get_color(depth: int) -> str: 16 | """Return color hex code based on call depth (1-based).""" 17 | return DEPTH_COLORS[(depth - 1) % len(DEPTH_COLORS)] 18 | 19 | 20 | def build_indent(depth: int) -> str: 21 | """ 22 | Build indentation string with vertical bars and colors based on depth. 23 | 24 | Args: 25 | depth: Call depth (1-based). 26 | 27 | Returns: 28 | Indentation string with color-coded bars. 29 | """ 30 | if depth <= 1: 31 | return "" 32 | return "".join(f"[{get_color(i)}]│   [/] " for i in range(1, depth)) 33 | -------------------------------------------------------------------------------- /examples/nested_example.py: -------------------------------------------------------------------------------- 1 | from pyhunt import trace, logger 2 | 3 | 4 | @trace 5 | def multiply(a, b): 6 | return a * b 7 | 8 | 9 | @trace 10 | def calculate(numbers, loop_count): 11 | total = 0 12 | # For each number, multiply by 2 in a loop of loop_count times 13 | for num in numbers: 14 | for _ in range(loop_count): 15 | total += multiply(num, 2) 16 | return total 17 | 18 | 19 | @trace 20 | def process_data(data, loop_count): 21 | processed = [x + 1 for x in data] 22 | result = calculate(processed, loop_count) 23 | return result 24 | 25 | 26 | @trace 27 | def main(): 28 | data = [1, 2, 3] 29 | loop_count = 10 30 | 31 | final_result = process_data(data, loop_count) 32 | return final_result 33 | 34 | 35 | if __name__ == "__main__": 36 | output = main() 37 | logger.info(f"Final output: {output}") 38 | -------------------------------------------------------------------------------- /examples/async_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pyhunt import trace, logger 3 | 4 | 5 | @trace 6 | async def plus_one(param): 7 | result = param + 1 8 | return result 9 | 10 | 11 | @trace 12 | def multiple(param): 13 | result = param * 2 14 | return result 15 | 16 | 17 | @trace 18 | async def async_level2(param): 19 | # Call sync function which returns coroutine, then await it 20 | added = param + 1 21 | 22 | result = await plus_one(added) 23 | multiple_result = multiple(result) 24 | 25 | return multiple_result 26 | 27 | 28 | @trace 29 | def level1(param): 30 | result = asyncio.run(async_level2(param)) 31 | return result 32 | 33 | 34 | @trace 35 | def main(): 36 | # Call sync async_level1, which returns coroutine, then run it 37 | final_result = level1(2) 38 | logger.info(f"Final result: {final_result}") 39 | 40 | 41 | if __name__ == "__main__": 42 | main() 43 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs"] 3 | build-backend = "hatchling.build" 4 | 5 | [tool.hatch.build.targets.wheel] 6 | packages = ["pyhunt"] 7 | 8 | [project.scripts] 9 | hunt = "pyhunt.cli:main" 10 | 11 | [project] 12 | name = "pyhunt" 13 | authors = [{ name = "EasyDev", email = "easydevv@gmail.com" }] 14 | description = "Lightweight Python logging tool for visual call tracing, tree-structured colored logs, and easy debugging with a simple decorator. Optimized for both standard and AI-generated codebases. " 15 | readme = "README.md" 16 | requires-python = ">=3.12" 17 | classifiers = [ 18 | "Programming Language :: Python :: 3.12", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | ] 22 | dependencies = ["dotenv>=0.9.9"] 23 | version = "1.2.0" 24 | 25 | [project.urls] 26 | source = "https://github.com/easydevv/pyhunt" 27 | 28 | [dependency-groups] 29 | dev = ["pytest-asyncio"] 30 | 31 | 32 | [tool.pytest.ini_options] 33 | testpaths = ["test"] 34 | -------------------------------------------------------------------------------- /pyhunt/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() 5 | 6 | 7 | # Log level mapping similar to Python's logging module 8 | LOG_LEVELS = { 9 | "debug": 10, 10 | "info": 20, 11 | "warning": 30, 12 | "error": 40, 13 | "critical": 50, 14 | } 15 | # Read log level from environment variable, default to INFO 16 | _log_level_name = os.getenv("HUNT_LEVEL", "INFO").lower() 17 | LOG_LEVEL = LOG_LEVELS.get(_log_level_name, 20) # default INFO if invalid 18 | 19 | ROOT_DIR = os.getenv("ROOT_DIR") 20 | 21 | # Read max log count from environment variable, default to None (unlimited) 22 | MAX_REPEAT = int(os.getenv("HUNT_MAX_REPEAT", 3)) 23 | 24 | # Read elapsed time display setting from environment variable, default to True 25 | ELAPSED = os.getenv("ELAPSED", "True").lower() in ("true", "1", "yes") 26 | 27 | # Read color setting from environment variable, default to True 28 | COLOR_ENABLED = os.getenv("HUNT_COLOR", "true").lower() in ("true", "yes", "1") 29 | 30 | # Read log file setting from environment variable, default to None (no logging) 31 | LOG_FILE = os.getenv("HUNT_LOG_FILE") 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 EasyDev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyhunt/utils.py: -------------------------------------------------------------------------------- 1 | def extract_first_traceback(traceback_text): 2 | """ 3 | Extracts and returns the first traceback block from a traceback string. 4 | 5 | Parameters: 6 | traceback_text (str): The full traceback string. 7 | 8 | Returns: 9 | str: The first traceback block, including the error message. 10 | """ 11 | lines = traceback_text.splitlines() 12 | first_tb_lines = [] 13 | in_first_tb = False 14 | 15 | for line in lines: 16 | if line.startswith("Traceback (most recent call last):"): 17 | if not in_first_tb: 18 | in_first_tb = True 19 | first_tb_lines.append(line) 20 | elif in_first_tb: 21 | # Encountered another traceback start, stop at the first one 22 | break 23 | elif in_first_tb: 24 | # Stop if we reach the "During handling..." line 25 | if ( 26 | "During handling of the above exception, another exception occurred:" 27 | in line 28 | ): 29 | break 30 | first_tb_lines.append(line) 31 | 32 | return "\n".join(first_tb_lines) 33 | -------------------------------------------------------------------------------- /examples/outputs/class_example.txt: -------------------------------------------------------------------------------- 1 | ├─▶ 1 🟢 Entry main | examples/class_example.py:16 2 | │    ├─▶ 2 🟢 Entry Calculator.add | examples/class_example.py:6 3 | │    │  { 4 | │    │   "a": 3, 5 | │    │   "b": 4 6 | │    │  } 7 | │    │  2 INFO Adding 3 + 4 8 | │    ├── 2 🔳 Exit Calculator.add 9 | │    ├─▶ 2 🟢 Entry Calculator.multiply | examples/class_example.py:10 10 | │    │  { 11 | │    │   "a": 3, 12 | │    │   "b": 4 13 | │    │  } 14 | │    │  2 INFO Multiplying 3 * 4 15 | │    ├── 2 🔳 Exit Calculator.multiply 16 | │  1 INFO Sum result: 7 17 | │  1 INFO Product result: 12 18 | ├── 1 🔳 Exit main 19 | -------------------------------------------------------------------------------- /test/prepare.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os 3 | import pathlib 4 | 5 | example_files = set() 6 | 7 | for dir_path, dir_names, file_names in pathlib.Path("examples").walk(): 8 | if "main.py" in file_names: 9 | example_files.add(dir_path / "main.py") 10 | else: 11 | for file_name in file_names: 12 | if file_name.endswith(".py") and file_name != "all_example.py": 13 | example_files.add(dir_path / file_name) 14 | 15 | example_files = list(sorted(example_files)) 16 | 17 | output_dir = pathlib.Path("examples/outputs") 18 | output_dir.mkdir(parents=True, exist_ok=True) 19 | 20 | for file_path_obj in example_files: 21 | relative_path_obj = file_path_obj.relative_to("examples") 22 | # If the file is main.py, use the parent directory name for the output file 23 | if file_path_obj.name == "main.py": 24 | output_file_name = f"{file_path_obj.parent.name}.txt" 25 | else: 26 | # Otherwise, use the file stem and replace separators 27 | output_file_name = f"{relative_path_obj.stem.replace(os.sep, '_')}.txt" 28 | output_file_path_obj = output_dir / output_file_name 29 | 30 | print(f"Running {file_path_obj}...") 31 | try: 32 | # Copy the current environment and update ELAPSED 33 | env = os.environ.copy() 34 | env["ELAPSED"] = "false" 35 | result = subprocess.run( 36 | ["uv", "run", "python", str(file_path_obj)], 37 | capture_output=True, 38 | text=True, 39 | check=True, 40 | cwd=".", 41 | env=env, 42 | ) 43 | with open(output_file_path_obj, "w") as f: 44 | f.write(f"{result.stdout}{result.stderr}") 45 | print(f"Output saved to {output_file_path_obj}") 46 | except subprocess.CalledProcessError as e: 47 | print(f"Error running {file_path_obj}: {e}") 48 | with open(output_file_path_obj, "w") as f: 49 | f.write(f"{e.stdout}{e.stderr}") 50 | print(f"Error output saved to {output_file_path_obj}") 51 | except FileNotFoundError: 52 | print("Error: uv command not found. Make sure uv is in your PATH.") 53 | break 54 | 55 | print("Finished running examples.") 56 | -------------------------------------------------------------------------------- /examples/type_example.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Union 2 | from pyhunt import trace, logger 3 | 4 | 5 | @trace 6 | def add(a: Union[int, float], b: Union[int, float]) -> Union[int, float]: 7 | return a + b 8 | 9 | 10 | @trace 11 | def concat_strings(s1: str, s2: str, sep: str = " ") -> str: 12 | return f"{s1}{sep}{s2}" 13 | 14 | 15 | @trace 16 | def repeat_string(s: str, times: int) -> str: 17 | return s * times 18 | 19 | 20 | @trace 21 | def combine_data(num: int, text: str, flag: bool) -> str: 22 | if flag: 23 | return f"{text} repeated {num} times: " + (text * num) 24 | else: 25 | return f"Number: {num}, Text: {text}" 26 | 27 | 28 | @trace 29 | def sum_list(numbers: List[int]) -> int: 30 | return sum(numbers) 31 | 32 | 33 | @trace 34 | def get_dict_keys(d: Dict[str, int]) -> List[str]: 35 | return list(d.keys()) 36 | 37 | 38 | @trace 39 | def merge_dicts(d1: Dict[str, int], d2: Dict[str, int]) -> Dict[str, int]: 40 | merged = d1.copy() 41 | merged.update(d2) 42 | return merged 43 | 44 | 45 | @trace 46 | def main() -> None: 47 | # Test add with integers 48 | result1 = add(5, 7) 49 | # Test add with floats 50 | result2 = add(3.5, 2.1) 51 | # Test concat_strings with default separator 52 | result3 = concat_strings("Hello", "World") 53 | # Test concat_strings with custom separator 54 | result4 = concat_strings("Hello", "World", sep=", ") 55 | # Test repeat_string 56 | result5 = repeat_string("abc", 3) 57 | # Test combine_data with flag True 58 | result6 = combine_data(4, "test", True) 59 | # Test combine_data with flag False 60 | result7 = combine_data(10, "example", False) 61 | # Test sum_list with a list of numbers 62 | result8 = sum_list([1, 2, 3, 4, 5]) 63 | # Test get_dict_keys with a dictionary 64 | result9 = get_dict_keys({"a": 1, "b": 2, "c": 3}) 65 | # Test merge_dicts with two dictionaries 66 | result10 = merge_dicts({"x": 1, "y": 2}, {"y": 3, "z": 4}) 67 | 68 | logger.info("Results:") 69 | logger.info(f"add(5, 7) = {result1}") 70 | logger.info(f"add(3.5, 2.1) = {result2}") 71 | logger.info(f'concat_strings("Hello", "World") = {result3}') 72 | logger.info(f'concat_strings("Hello", "World", sep=", ") = {result4}') 73 | logger.info(f'repeat_string("abc", 3) = {result5}') 74 | logger.info(f'combine_data(4, "test", True) = {result6}') 75 | logger.info(f'combine_data(10, "example", False) = {result7}') 76 | logger.info(f"sum_list([1, 2, 3, 4, 5]) = {result8}") 77 | logger.info(f'get_dict_keys({{"a": 1, "b": 2, "c": 3}}) = {result9}') 78 | logger.info(f'merge_dicts({{"x": 1, "y": 2}}, {{"y": 3, "z": 4}}) = {result10}') 79 | 80 | 81 | if __name__ == "__main__": 82 | main() 83 | -------------------------------------------------------------------------------- /examples/outputs/error_example.txt: -------------------------------------------------------------------------------- 1 | ├─▶ 1 🟢 Entry level_one | examples/error_example.py:23 2 | │  { 3 | │   "value": 5 4 | │  } 5 | │  1 INFO Inside level_one, calling level_two. 6 | │    ├─▶ 2 🟢 Entry level_two | examples/error_example.py:17 7 | │    │  { 8 | │    │   "value": 4 9 | │    │  } 10 | │    │  2 INFO Inside level_two, calling level_three. 11 | │    │    ├─▶ 3 🟢 Entry level_three | examples/error_example.py:5 12 | │    │    │  { 13 | │    │    │   "value": 3 14 | │    │    │  } 15 | │    │    │  3 INFO Inside level_three, about to raise TypeError. 16 | │    │    └── 3 🟥 Error level_three | examples/error_example.py:13 17 | │    │    │  TypeError: can only concatenate str (not "int") to str 18 | │    │    │  { 19 | │    │    │   "value": 3 20 | │    │    │  } 21 | Traceback (most recent call last): 22 | File "C:\Projects\Public\pyhunt\examples\error_example.py", line 10, in level_three 23 | result = "number: " + value 24 | ~~~~~~~~~~~^~~~~~~ 25 | TypeError: can only concatenate str (not "int") to str 26 | -------------------------------------------------------------------------------- /test/test_examples.py: -------------------------------------------------------------------------------- 1 | import re 2 | import asyncio 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | EXAMPLE_DIR = Path("examples") 8 | OUTPUT_DIR = EXAMPLE_DIR / "outputs" 9 | 10 | 11 | def get_example_files(): 12 | """Gets a list of Python files in the examples directory and their expected output filenames.""" 13 | example_files = [] 14 | for example_file_path in EXAMPLE_DIR.rglob("*.py"): 15 | relative_path = example_file_path.relative_to(EXAMPLE_DIR) 16 | 17 | # Exclude files in subdirectories that are not main.py 18 | if len(relative_path.parts) > 1 and relative_path.name != "main.py": 19 | continue 20 | 21 | if not example_file_path.name.startswith("__"): 22 | # Generate output filename based on the example file name 23 | if len(relative_path.parts) > 1 and relative_path.name == "main.py": 24 | output_filename = relative_path.parts[0] + ".txt" 25 | else: 26 | output_filename = relative_path.with_suffix(".txt").name 27 | example_files.append((str(example_file_path), output_filename)) 28 | return example_files 29 | 30 | 31 | @pytest.mark.parametrize("example_file, output_filename", get_example_files()) 32 | @pytest.mark.asyncio 33 | async def test_example_output(example_file, output_filename): 34 | """Tests the output of each example file against its expected output asynchronously.""" 35 | expected_output_path = OUTPUT_DIR / output_filename 36 | example_file_path = Path(example_file) # Convert to Path for easier comparison 37 | 38 | process = await asyncio.create_subprocess_exec( 39 | "uv", 40 | "run", 41 | "python", 42 | example_file, 43 | stdout=asyncio.subprocess.PIPE, 44 | stderr=asyncio.subprocess.PIPE, 45 | cwd=".", 46 | ) 47 | stdout, stderr = await process.communicate() 48 | 49 | # Special handling for the error example 50 | if example_file_path.name == "error_example.py": 51 | # Expecting a non-zero return code for the error example 52 | assert process.returncode != 0, ( 53 | f"Expected {example_file} to fail, but it succeeded." 54 | ) 55 | 56 | else: # Original logic for successful examples 57 | assert expected_output_path.exists(), ( 58 | f"Expected output file not found: {expected_output_path}" 59 | ) 60 | 61 | if process.returncode != 0: 62 | pytest.fail( 63 | f"Subprocess for {example_file} failed unexpectedly with return code {process.returncode}:\n{stderr.decode()}" 64 | ) 65 | 66 | actual_output = stdout.decode().strip() 67 | 68 | # Remove ANSI escape codes and timing information 69 | ansi_escape = re.compile(r"\x1b\[[0-9;]*m") 70 | actual_output = ansi_escape.sub("", actual_output) 71 | actual_output = re.sub(r" \| \d+\.\d+s", "", actual_output) 72 | 73 | # Read the expected output 74 | with open(expected_output_path, "r", encoding="utf-8") as f: 75 | expected_output = f.read().strip() 76 | 77 | # Remove ANSI escape codes from expected output 78 | expected_output = ansi_escape.sub("", expected_output) 79 | 80 | assert actual_output == expected_output, f"Output mismatch for {example_file}" 81 | -------------------------------------------------------------------------------- /examples/outputs/async_example.txt: -------------------------------------------------------------------------------- 1 | ├─▶ 1 🟢 Entry main | examples/async_example.py:35 2 | │    ├─▶ 2 🟢 Entry level1 | examples/async_example.py:29 3 | │    │  { 4 | │    │   "param": 2 5 | │    │  } 6 | │    │    ├─▶ 3 🟢 Entry async async_level2 | examples/async_example.py:18 7 | │    │    │  { 8 | │    │    │   "param": 2 9 | │    │    │  } 10 | │    │    │    ├─▶ 4 🟢 Entry async plus_one | examples/async_example.py:6 11 | │    │    │    │  { 12 | │    │    │    │   "param": 3 13 | │    │    │    │  } 14 | │    │    │    ├── 4 🔳 Exit async plus_one 15 | │    │    │    ├─▶ 4 🟢 Entry multiple | examples/async_example.py:12 16 | │    │    │    │  { 17 | │    │    │    │   "param": 4 18 | │    │    │    │  } 19 | │    │    │    ├── 4 🔳 Exit multiple 20 | │    │    ├── 3 🔳 Exit async async_level2 21 | │    ├── 2 🔳 Exit level1 22 | │  1 INFO Final result: 8 23 | ├── 1 🔳 Exit main 24 | -------------------------------------------------------------------------------- /test/cli/test_cli_options.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import subprocess 4 | import pytest 5 | 6 | 7 | @pytest.fixture 8 | def temp_log_file(): 9 | with tempfile.NamedTemporaryFile(suffix=".log", delete=False) as f: 10 | path = f.name 11 | yield path 12 | if os.path.exists(path): 13 | os.unlink(path) 14 | 15 | 16 | @pytest.fixture 17 | def example_script(): 18 | script = """ 19 | from pyhunt import trace, logger 20 | 21 | @trace 22 | def test_function(): 23 | logger.info("Test message") 24 | return "success" 25 | 26 | if __name__ == "__main__": 27 | result = test_function() 28 | print(f"Result: {result}") 29 | """ 30 | with tempfile.NamedTemporaryFile(suffix=".py", delete=False, mode="w") as f: 31 | f.write(script) 32 | path = f.name 33 | yield path 34 | if os.path.exists(path): 35 | os.unlink(path) 36 | 37 | 38 | def run_command(cmd, env_vars=None): 39 | env = os.environ.copy() 40 | if env_vars: 41 | env.update(env_vars) 42 | return subprocess.run(cmd, capture_output=True, text=True, cwd=".", env=env) 43 | 44 | 45 | @pytest.mark.parametrize( 46 | "args, expected", 47 | [ 48 | (["--color", "true"], "Color output set to 'true'"), 49 | (["--color", "false"], "Color output set to 'false'"), 50 | ], 51 | ) 52 | def test_color_option(args, expected): 53 | result = run_command(["uv", "run", "hunt"] + args) 54 | assert result.returncode == 0 55 | assert expected in result.stdout 56 | 57 | 58 | @pytest.mark.parametrize("color", ["true", "false"]) 59 | def test_color_functionality(example_script, color): 60 | run_command(["uv", "run", "hunt", "--color", color]) 61 | result = run_command(["uv", "run", "python", example_script], {"HUNT_COLOR": color}) 62 | assert result.returncode == 0 63 | assert "Result: success" in result.stdout 64 | 65 | 66 | def test_log_file_option(temp_log_file): 67 | result = run_command(["uv", "run", "hunt", "--log-file", temp_log_file]) 68 | assert result.returncode == 0 69 | assert f"Log file set to '{temp_log_file}'" in result.stdout 70 | assert os.path.exists(temp_log_file) 71 | 72 | 73 | @pytest.mark.parametrize( 74 | "color, expect_emoji", 75 | [ 76 | ("true", True), 77 | ("false", False), 78 | ], 79 | ) 80 | def test_emoji_behavior(color, expect_emoji): 81 | script = """ 82 | import os 83 | os.environ['HUNT_COLOR'] = '{color}' 84 | os.environ['HUNT_LEVEL'] = 'DEBUG' 85 | from pyhunt import trace 86 | 87 | @trace 88 | def test_emoji_function(): 89 | return "test" 90 | if __name__ == "__main__": 91 | test_emoji_function() 92 | """.format(color=color) 93 | 94 | script_path = f"test_emoji_{color}.py" 95 | try: 96 | with open(script_path, "w") as f: 97 | f.write(script) 98 | 99 | result = run_command(["python", script_path]) 100 | assert result.returncode == 0 101 | 102 | if expect_emoji: 103 | assert any(e in result.stdout for e in ["🟢", "🔳", "🟥"]) 104 | else: 105 | assert "🟢" not in result.stdout 106 | assert "→" in result.stdout or "←" in result.stdout 107 | finally: 108 | if os.path.exists(script_path): 109 | os.unlink(script_path) 110 | -------------------------------------------------------------------------------- /examples/outputs/project_example.txt: -------------------------------------------------------------------------------- 1 | ├─▶ 1 🟢 Entry async main | project_example/main.py:7 2 | │    ├─▶ 2 🟢 Entry async TaskManager.add_task | project_example/manager.py:10 3 | │    │  { 4 | │    │   "name": "Write documentation" 5 | │    │  } 6 | │    │  2 INFO Adding task 'Write documentation'. 7 | │    ├── 2 🔳 Exit async TaskManager.add_task 8 | │    ├─▶ 2 🟢 Entry async TaskManager.add_task | project_example/manager.py:10 9 | │    │  { 10 | │    │   "name": "Implement feature X" 11 | │    │  } 12 | │    │  2 INFO Adding task 'Implement feature X'. 13 | │    ├── 2 🔳 Exit async TaskManager.add_task 14 | │    ├─▶ 2 🟢 Entry async TaskManager.complete_task | project_example/manager.py:16 15 | │    │  { 16 | │    │   "name": "Write documentation" 17 | │    │  } 18 | │    │    ├─▶ 3 🟢 Entry async Task.complete | project_example/task.py:10 19 | │    │    ├── 3 🔳 Exit async Task.complete 20 | │    │  2 INFO Task 'Write documentation' completed. 21 | │    ├── 2 🔳 Exit async TaskManager.complete_task 22 | │    ├─▶ 2 🟢 Entry async TaskManager.complete_task | project_example/manager.py:16 23 | │    │  { 24 | │    │   "name": "Nonexistent task" 25 | │    │  } 26 | │    │  2 WARNING Task 'Nonexistent task' not found or already completed. 27 | │    ├── 2 🔳 Exit async TaskManager.complete_task 28 | ├── 1 🔳 Exit async main 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI and Create Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main # when pushing to main 7 | 8 | jobs: 9 | check-version: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | version_changed: ${{ steps.check_version.outputs.version_changed }} 13 | new_version: ${{ steps.check_version.outputs.new_version }} 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 2 # get current, previous commits 20 | - name: Check if version changed 21 | id: check_version 22 | run: | 23 | git show HEAD~1:pyproject.toml > old_pyproject.toml || echo "Previous file not found" 24 | 25 | if [ -f old_pyproject.toml ]; then 26 | OLD_VERSION=$(grep -Po '(?<=version = ")[^"]*' old_pyproject.toml || echo "") 27 | NEW_VERSION=$(grep -Po '(?<=version = ")[^"]*' pyproject.toml || echo "") 28 | 29 | echo "Old version: $OLD_VERSION" 30 | echo "New version: $NEW_VERSION" 31 | 32 | if [ "$OLD_VERSION" != "$NEW_VERSION" ] && [ -n "$NEW_VERSION" ]; then 33 | echo "version_changed=true" >> $GITHUB_OUTPUT 34 | echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT 35 | echo "Version changed from $OLD_VERSION to $NEW_VERSION" 36 | else 37 | echo "version_changed=false" >> $GITHUB_OUTPUT 38 | echo "No version change detected or version not found" 39 | fi 40 | else 41 | echo "version_changed=false" >> $GITHUB_OUTPUT 42 | echo "Could not compare versions - previous file not found" 43 | fi 44 | 45 | build-publish-tag-release: 46 | needs: check-version 47 | if: needs.check-version.outputs.version_changed == 'true' 48 | runs-on: ubuntu-latest 49 | permissions: 50 | contents: write # must be set to write tag, release, and release assets 51 | 52 | steps: 53 | - name: Checkout code 54 | uses: actions/checkout@v5 55 | with: 56 | fetch-depth: 0 # get all commit history 57 | 58 | - name: Install uv 59 | uses: astral-sh/setup-uv@v6 60 | with: 61 | enable-cache: true 62 | 63 | - name: Install the project 64 | run: uv sync --all-extras --dev 65 | 66 | - name: Build package 67 | run: uv build 68 | 69 | - name: Publish to PyPI 70 | run: uv publish --token ${{ secrets.PYPI_API_TOKEN }} 71 | 72 | - name: Create Tag 73 | run: | 74 | VERSION=${{ needs.check-version.outputs.new_version }} 75 | git config --local user.email "action@github.com" 76 | git config --local user.name "GitHub Action" 77 | git tag -a "v$VERSION" -m "Release v$VERSION" 78 | git push origin "v$VERSION" 79 | 80 | - name: Generate changelog 81 | id: changelog 82 | run: | 83 | PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") 84 | if [ -z "$PREV_TAG" ]; then 85 | CHANGELOG=$(git log --pretty=format:"- %s" ${{ github.sha }}) 86 | else 87 | CHANGELOG=$(git log --pretty=format:"- %s" ${PREV_TAG}..HEAD) 88 | fi 89 | echo "CHANGELOG<> $GITHUB_OUTPUT 90 | echo "$CHANGELOG" >> $GITHUB_OUTPUT 91 | echo "EOF" >> $GITHUB_OUTPUT 92 | 93 | - name: Set repo name output 94 | id: repo_name 95 | run: echo "REPO_NAME=${GITHUB_REPOSITORY#$GITHUB_REPOSITORY_OWNER/}" >> $GITHUB_OUTPUT 96 | 97 | - name: Create GitHub Release 98 | uses: softprops/action-gh-release@v2 99 | with: 100 | tag_name: v${{ needs.check-version.outputs.new_version }} 101 | body: | 102 | ## 🔄Changes 103 | ${{ steps.changelog.outputs.CHANGELOG }} 104 | 105 | ## 📦Update 106 | ```bash 107 | uv add ${{ steps.repo_name.outputs.REPO_NAME }} --upgrade 108 | ``` -------------------------------------------------------------------------------- /README_KR.md: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | pyhunt_logo 5 | 6 | # pyhunt 7 | 8 | `pyhunt`는 로그를 시각적으로 표현하여 빠른 구조 파악과 디버깅을 지원하는 9 | 경량 로깅 도구입니다. 함수에 데코레이터만 추가하면, 10 | 모든 로그를 자동으로 추적하여 터미널에 출력합니다. 11 | 12 | [![PyPI version](https://img.shields.io/pypi/v/pyhunt.svg)](https://pypi.org/project/pyhunt/) 13 | [![Python Versions](https://img.shields.io/pypi/pyversions/pyhunt.svg)](https://pypi.org/project/pyhunt/) 14 | 15 | #### [English](/README.md) | 한국어 16 | 17 | --- 18 | 19 | https://github.com/user-attachments/assets/3d4389fe-4708-423a-812e-25f2e7200053 20 | 21 | pyhunt_description 22 | 23 |
24 | 25 | ## 주요 특징 26 | 27 | - **자동 함수/메서드 호출 추적**: `@trace` 데코레이터 하나로 동기/비동기 함수, 클래스 호출 흐름을 자동 기록 28 | - **풍부한 색상과 트리 구조 로그**: 호출 뎁스에 따른 색상 및 인덴트로 가독성 향상 29 | - **다양한 로그 레벨 지원**: DEBUG, INFO, WARNING, ERROR, CRITICAL 30 | - **CLI를 통한 로그 레벨 설정**: `.env` 파일에 `HUNT_LEVEL` 저장 및 관리 31 | - **AI 워크플로우에 최적화**: AI가 생성한 코드를 손쉽게 추적할 수 있습니다. 32 | - **예외 발생 시 상세 정보 제공**: 호출 인자, 위치, 스택트레이스 포함 33 | 34 | 35 | ## 설치 방법 36 | 37 | ### pip 을 이용해 설치 38 | ```bash 39 | pip install pyhunt 40 | ``` 41 | 42 | ### uv 를 이용해 설치 43 | ```bash 44 | uv add pyhunt 45 | ``` 46 | 47 | ## 빠른 시작 48 | 49 | ### 1. 환경변수 파일 설정 및 관리 50 | `hunt` 명령어를 실행하여 `.env` 파일을 설정하고 관리할 수 있습니다. 51 | 52 | ```bash 53 | hunt 54 | ``` 55 | 56 | 위 명령어를 실행하면 `.env` 파일에 `HUNT_LEVEL=DEBUG`와 `ROOT_DIR`이 현재 디렉토리로 설정됩니다. 57 | 58 | 59 | ### 2. 함수 또는 클래스에 `@trace` 적용 60 | 자세한 예제는 [examples](https://github.com/pyhunt/pyhunt/tree/main/examples) 폴더를 참고하세요. 61 | 62 | 63 | #### 기본 예제 64 | ```py 65 | from pyhunt import trace 66 | 67 | @trace 68 | def test(value): 69 | return value 70 | ``` 71 | 72 | #### 비동기 함수 73 | ```py 74 | @trace 75 | async def test(value): 76 | return value 77 | ``` 78 | 79 | #### 클래스 80 | ```py 81 | @trace 82 | class MyClass: 83 | def first_method(self, value): 84 | return value 85 | 86 | def second_method(self, value): 87 | return value 88 | ``` 89 | 90 | ## AI와 함께 사용 91 | 92 | ### 룰 셋업 93 | `AGENTS.md` 또는 `CLAUDE.md`에 다음 규칙을 추가합니다: 94 | 95 | ```md 96 | ## Logging Rules 97 | 98 | **Import:** Import the decorator with `from pyhunt import trace`. 99 | **Tracing:** Use the `@trace` decorator to automatically log function calls and execution times. 100 | **Avoid `print()`:** Do not use the `print()` function. 101 | **Exception Handling:** Use `try`/`except Exception as e: raise e` blocks to maintain traceback. 102 | ``` 103 | 104 | ### 기존 코드베이스 수정 105 | 프롬프트: **"로깅 규칙에 따라 코드를 수정하시오."** 106 | 107 | ## Logger 사용법 108 | 109 | `logger` 인터페이스는 중요한 섹션에서만 사용하는 것을 권장합니다. 110 | 대부분의 동작은 `@trace`를 통해 추적되며, 과도한 사용은 가독성을 떨어뜨릴 수 있습니다. 111 | 112 | ```py 113 | from pyhunt import logger 114 | 115 | logger.debug("This is a debug log.") 116 | logger.info("This is an info log.") 117 | logger.warning("This is a warning log.") 118 | logger.error("This is an error log.") 119 | logger.critical("This is a critical log.") 120 | ``` 121 | 122 | 123 | ## CLI 사용법 124 | 125 | `hunt` 명령어를 사용하여 로그 레벨 및 기타 설정을 관리할 수 있습니다. 126 | 127 | ```bash 128 | hunt [옵션] 129 | ``` 130 | 131 | ### 지원 옵션 132 | 133 | - `--debug`, `--info`, `--warning`, `--error`, `--critical` : 로그 레벨 설정 (DEBUG, INFO, WARNING, ERROR, CRITICAL) 134 | ```bash 135 | hunt --debug # 가장 상세한 로그 136 | hunt --info # 정보 로그 137 | hunt --warning # 경고 로그 138 | hunt --error # 에러 로그 139 | hunt --critical # 크리티컬 로그만 140 | ``` 141 | 142 | - `--root` : `ROOT_DIR` 환경 변수를 현재 디렉토리로 설정합니다. 143 | ```bash 144 | hunt --root 145 | ``` 146 | 147 | - `--repeat <횟수>` : `HUNT_MAX_REPEAT` 환경 변수를 지정된 횟수로 설정합니다. (로그 반복 제한) 148 | ```bash 149 | hunt --repeat 5 150 | ``` 151 | 152 | - `--color ` : 로그 출력 시 색상 사용을 활성화하거나 비활성화합니다. 153 | ```bash 154 | hunt --color false 155 | ``` 156 | 157 | - `--log-file <파일>` : 로그 파일 출력을 설정합니다. 파일을 지정하지 않으면 기본값으로 `.pyhunt.log`가 사용됩니다. 158 | ```bash 159 | hunt --log-file 160 | ``` 161 | 162 | 옵션 미지정 시 기본값은 `DEBUG`입니다. 163 | 164 | ### 환경 변수 165 | 166 | `pyhunt`는 `.env` 파일을 통해 다음 환경 변수를 지원합니다. 167 | 168 | - `HUNT_LEVEL`: 로그 레벨 설정 (DEBUG, INFO, WARNING, ERROR, CRITICAL). 기본값은 `DEBUG`입니다. 169 | - `HUNT_MAX_REPEAT`: 동일한 로그가 반복될 때 표시를 제한하는 횟수입니다. 기본값은 3입니다. 170 | - `ELAPSED`: 로그에 함수 실행 시간을 표시할지 여부를 설정합니다. (`True` 또는 `False`). 기본값은 `True`입니다. 171 | - `HUNT_COLOR`: 로그 출력 시 색상 사용 여부를 설정합니다. (`True` 또는 `False`). 기본값은 `True`입니다. 172 | - `HUNT_LOG_FILE`: 로그 출력 파일 경로를 설정합니다. 지정하지 않으면 터미널에만 로그가 표시됩니다. 173 | - `ROOT_DIR`: 로그 출력 시 기준 디렉토리를 설정합니다. 보다 정확하게 경로를 표시합니다. 174 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | 176 | .vscode/* 177 | !.vscode/settings.json 178 | !.vscode/tasks.json 179 | !.vscode/launch.json 180 | !.vscode/extensions.json 181 | !.vscode/*.code-snippets 182 | 183 | # Local History for Visual Studio Code 184 | .history/ 185 | 186 | # Built Visual Studio Code Extensions 187 | *.vsix 188 | 189 | ## User-specific files 190 | .test* 191 | 192 | .claude -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | `pyhunt` is a lightweight Python logging tool that automatically traces function calls and provides visual, tree-structured colored logs for debugging. It uses a simple `@trace` decorator to log function execution times, call hierarchies, and exceptions. 8 | 9 | ## Development Commands 10 | 11 | ### Package Management 12 | ```bash 13 | # Install dependencies 14 | uv sync 15 | 16 | # Build the package 17 | uv build 18 | ``` 19 | 20 | ### Python Execution 21 | ```bash 22 | # Run Python files with uv 23 | uv run python filename.py 24 | 25 | # Run Python modules with uv 26 | uv run python -m module_name 27 | 28 | # Run Python interactively with uv 29 | uv run python 30 | ``` 31 | 32 | 33 | ### Testing 34 | ```bash 35 | # Run all tests 36 | uv run pytest 37 | 38 | # Run specific test file 39 | uv run pytest test/test_examples.py 40 | 41 | # Run tests with verbose output 42 | uv run pytest -v 43 | ``` 44 | 45 | ### Code Quality 46 | ```bash 47 | # Lint all files in the current directory 48 | uvx ruff check 49 | 50 | # Format all files in the current directory 51 | uvx ruff format 52 | ``` 53 | 54 | ### CLI Tool 55 | ```bash 56 | # Set up environment file (creates .env with DEBUG level) 57 | hunt 58 | 59 | # Set specific log levels 60 | hunt --debug 61 | hunt --info 62 | hunt --warning 63 | hunt --error 64 | hunt --critical 65 | 66 | # Set root directory and repeat limit 67 | hunt --root 68 | hunt --repeat 5 69 | ``` 70 | 71 | 72 | ## Core Architecture 73 | 74 | ### Main Components 75 | - **`pyhunt/decorator.py`**: Core `@trace` decorator implementation for function call tracing 76 | - **`pyhunt/cli.py`**: Command-line interface for managing log levels and environment variables 77 | - **`pyhunt/logger.py`**: Direct logging interface for manual log messages 78 | - **`pyhunt/console.py`**: Console output formatting and color handling 79 | - **`pyhunt/config.py`**: Environment variable configuration and `.env` file management 80 | - **`pyhunt/colors.py`**: Color definitions for different log levels 81 | - **`pyhunt/context.py`**: Call context management for tracking execution depth 82 | - **`pyhunt/utils.py`**: Utility functions for path handling and formatting 83 | - **`pyhunt/helpers.py`**: Helper functions for decorator and logging operations 84 | 85 | ### Key Features 86 | - **Automatic Tracing**: The `@trace` decorator automatically logs function entry, exit, execution time, and exceptions 87 | - **Tree-structured Output**: Logs are indented based on call depth for visual hierarchy 88 | - **Multiple Log Levels**: DEBUG, INFO, WARNING, ERROR, CRITICAL with color coding 89 | - **Async Support**: Full support for async/await functions 90 | - **Class Integration**: Can be applied to entire classes to trace all methods 91 | - **Environment Configuration**: Manages log levels through `.env` file and CLI 92 | 93 | ## Usage Patterns 94 | 95 | ### Basic Usage 96 | ```python 97 | from pyhunt import trace 98 | 99 | @trace 100 | def my_function(param): 101 | return param * 2 102 | 103 | @trace 104 | async def async_function(param): 105 | return await some_async_call() 106 | 107 | @trace 108 | class MyClass: 109 | def method_one(self): 110 | pass 111 | 112 | def method_two(self): 113 | pass 114 | ``` 115 | 116 | ### Direct Logging 117 | ```python 118 | from pyhunt import logger 119 | 120 | logger.debug("Debug message") 121 | logger.info("Info message") 122 | logger.warning("Warning message") 123 | logger.error("Error message") 124 | logger.critical("Critical message") 125 | ``` 126 | 127 | ## Configuration 128 | 129 | The tool supports these environment variables (managed via `.env` file): 130 | - `HUNT_LEVEL`: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) 131 | - `HUNT_MAX_REPEAT`: Maximum number of repeated log displays 132 | - `ELAPSED`: Whether to show execution time (True/False) 133 | - `ROOT_DIR`: Base directory for path display 134 | 135 | ## AI Integration Rules 136 | 137 | For AI-generated code, follow these patterns: 138 | - Import: `from pyhunt import trace` 139 | - Use `@trace` decorator instead of `print()` statements 140 | - For exceptions, use `try/except Exception as e: raise e` to maintain traceback 141 | - Apply `@trace` to classes and methods automatically when adding logging 142 | - Execute Python files with `uv run python` instead of just `python` 143 | 144 | ## Testing 145 | 146 | Tests are located in the `test/` directory and use pytest. The main test file `test_examples.py` runs all example files and compares their output against expected results stored in `examples/outputs/`. -------------------------------------------------------------------------------- /pyhunt/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | from dotenv import load_dotenv 4 | from pathlib import Path 5 | 6 | from pyhunt.config import LOG_LEVELS 7 | from pyhunt.console import Console 8 | 9 | console = Console() 10 | env_path = Path.cwd() / ".env" 11 | 12 | 13 | def load_env() -> dict: 14 | """Load existing .env into dict.""" 15 | load_dotenv(env_path, override=True) 16 | env_vars = {} 17 | if env_path.exists(): 18 | with env_path.open("r") as f: 19 | for line in f: 20 | if "=" in line: 21 | key, value = line.strip().split("=", 1) 22 | env_vars[key] = value 23 | return env_vars 24 | 25 | 26 | def save_env(env_vars: dict): 27 | """Write dict back into .env file with preferred order.""" 28 | order = ["ROOT_DIR", "HUNT_LEVEL", "HUNT_MAX_REPEAT", "HUNT_COLOR", "HUNT_LOG_FILE"] 29 | with env_path.open("w") as f: 30 | for key in order: 31 | if key in env_vars: 32 | f.write(f"{key}={env_vars.pop(key)}\n") 33 | for key, value in env_vars.items(): 34 | f.write(f"{key}={value}\n") 35 | 36 | 37 | def update_env_vars(new_vars: dict): 38 | """Update or create .env file with provided vars.""" 39 | env_vars = load_env() 40 | env_vars.update({k: str(v) for k, v in new_vars.items()}) 41 | save_env(env_vars) 42 | 43 | 44 | def print_log_level_message(level_name: str): 45 | """Print visible log levels for given level.""" 46 | level_name = level_name.lower() 47 | level_value = LOG_LEVELS.get(level_name, 20) 48 | visible_levels = [n.upper() for n, v in LOG_LEVELS.items() if v >= level_value] 49 | 50 | colors = { 51 | "debug": "cyan", 52 | "info": "green", 53 | "warning": "yellow", 54 | "error": "red", 55 | "critical": "bold red", 56 | } 57 | colored_level = f"[{colors.get(level_name, 'white')}]{level_name.upper()}[/]" 58 | colored_visible = [ 59 | f"[{colors.get(level.lower(), 'white')}]{level}[/]" for level in visible_levels 60 | ] 61 | 62 | console.print( 63 | f"HUNT_LEVEL set to '{colored_level}'. " 64 | f"You will see logs with levels: {', '.join(colored_visible)}." 65 | ) 66 | 67 | 68 | def main(): 69 | parser = argparse.ArgumentParser(prog="hunt", description="Pythunt CLI tool") 70 | 71 | group = parser.add_mutually_exclusive_group() 72 | for lvl in ["debug", "info", "warning", "error", "critical"]: 73 | group.add_argument( 74 | f"--{lvl}", action="store_true", help=f"Set log level to {lvl.upper()}" 75 | ) 76 | 77 | parser.add_argument( 78 | "--root", action="store_true", help="Set ROOT_DIR to current directory" 79 | ) 80 | parser.add_argument("--repeat", type=int, help="Set HUNT_MAX_REPEAT") 81 | parser.add_argument( 82 | "--color", choices=["true", "false"], help="Enable/disable color output" 83 | ) 84 | parser.add_argument( 85 | "--log-file", 86 | nargs="?", 87 | const=".pyhunt.log", 88 | help="Set log file (default: .pyhunt.log)", 89 | ) 90 | 91 | # If `--` is present, parse only args after separator 92 | args = parser.parse_args( 93 | sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else None 94 | ) 95 | 96 | updates = {} 97 | 98 | # Defaults when no args 99 | if not sys.argv[1:]: 100 | updates["HUNT_LEVEL"] = "DEBUG" 101 | updates["ROOT_DIR"] = str(Path.cwd()) 102 | print_log_level_message("debug") 103 | console.print(f"ROOT_DIR set to '{updates['ROOT_DIR']}'") 104 | else: 105 | # Log level 106 | for lvl in ["debug", "info", "warning", "error", "critical"]: 107 | if getattr(args, lvl): 108 | updates["HUNT_LEVEL"] = lvl.upper() 109 | print_log_level_message(lvl) 110 | break 111 | 112 | if args.root: 113 | updates["ROOT_DIR"] = str(Path.cwd()) 114 | console.print(f"ROOT_DIR set to '{updates['ROOT_DIR']}'") 115 | 116 | if args.repeat is not None: 117 | updates["HUNT_MAX_REPEAT"] = args.repeat 118 | console.print(f"HUNT_MAX_REPEAT set to '{args.repeat}'") 119 | 120 | if args.color: 121 | updates["HUNT_COLOR"] = args.color 122 | console.print(f"Color output set to '{args.color}'") 123 | 124 | if args.log_file is not None: 125 | # When --log-file is used, const='.pyhunt.log' is automatically used 126 | updates["HUNT_LOG_FILE"] = args.log_file 127 | console.print(f"Log file set to '{updates['HUNT_LOG_FILE']}'") 128 | # Don't set default log file if not explicitly requested 129 | 130 | update_env_vars(updates) 131 | 132 | 133 | if __name__ == "__main__": 134 | main() 135 | -------------------------------------------------------------------------------- /examples/outputs/nested_example.txt: -------------------------------------------------------------------------------- 1 | ├─▶ 1 🟢 Entry main | examples/nested_example.py:27 2 | │    ├─▶ 2 🟢 Entry process_data | examples/nested_example.py:20 3 | │    │  { 4 | │    │   "data": [1, 2, 3], 5 | │    │   "loop_count": 10 6 | │    │  } 7 | │    │    ├─▶ 3 🟢 Entry calculate | examples/nested_example.py:10 8 | │    │    │  { 9 | │    │    │   "numbers": [2, 3, 4], 10 | │    │    │   "loop_count": 10 11 | │    │    │  } 12 | │    │    │    ├─▶ 4 🟢 Entry multiply | examples/nested_example.py:5 13 | │    │    │    │  { 14 | │    │    │    │   "a": 2, 15 | │    │    │    │   "b": 2 16 | │    │    │    │  } 17 | │    │    │    ├── 4 🔳 Exit multiply 18 | │    │    │    ├─▶ 4 🟢 Entry multiply | examples/nested_example.py:5 19 | │    │    │    │  { 20 | │    │    │    │   "a": 2, 21 | │    │    │    │   "b": 2 22 | │    │    │    │  } 23 | │    │    │    ├── 4 🔳 Exit multiply 24 | │    │    │    ├─▶ 4 🟢 Entry multiply | examples/nested_example.py:5 25 | │    │    │    │  { 26 | │    │    │    │   "a": 2, 27 | │    │    │    │   "b": 2 28 | │    │    │    │  } 29 | │    │    │    ├── 4 🔳 Exit multiply 30 | │    │    │    ├─▶  ... Repeated logs have been omitted | MAX_REPEAT: 3 31 | │    │    ├── 3 🔳 Exit calculate 32 | │    ├── 2 🔳 Exit process_data 33 | ├── 1 🔳 Exit main 34 | │  0 INFO Final output: 180 35 | -------------------------------------------------------------------------------- /pyhunt/helpers.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import json 3 | from pathlib import Path 4 | from typing import Any, Callable, Dict 5 | 6 | MAX_ARG_LENGTH = 50 7 | 8 | 9 | def format_call_args(func: Callable, args: tuple, kwargs: dict) -> Dict[str, Any]: 10 | """ 11 | Format function call arguments into a dictionary. 12 | 13 | Args: 14 | func: The function being called. 15 | args: Positional arguments. 16 | kwargs: Keyword arguments. 17 | 18 | Returns: 19 | Dictionary of argument names to their string representations. 20 | """ 21 | try: 22 | bound = inspect.signature(func).bind(*args, **kwargs) 23 | bound.apply_defaults() 24 | return { 25 | k: str(v) if isinstance(v, Path) else v for k, v in bound.arguments.items() 26 | } 27 | except Exception: 28 | return {"args": repr(args), "kwargs": repr(kwargs)} 29 | 30 | 31 | def _truncate_long_strings(obj: Any, max_length: int = MAX_ARG_LENGTH) -> Any: 32 | """ 33 | Recursively truncate long strings in dicts, lists, tuples, and sets. 34 | 35 | Args: 36 | obj: The object to process. 37 | max_length: Maximum allowed string length before truncation. 38 | 39 | Returns: 40 | A copy of the object with long strings truncated and suffixed with '...'. 41 | """ 42 | if isinstance(obj, dict): 43 | return { 44 | ( 45 | _truncate_long_strings(k, max_length) if isinstance(k, str) else k 46 | ): _truncate_long_strings(v, max_length) 47 | for k, v in obj.items() 48 | } 49 | elif isinstance(obj, list): 50 | return [_truncate_long_strings(item, max_length) for item in obj] 51 | elif isinstance(obj, tuple): 52 | return tuple(_truncate_long_strings(item, max_length) for item in obj) 53 | elif isinstance(obj, set): 54 | return {_truncate_long_strings(item, max_length) for item in obj} 55 | elif isinstance(obj, str): 56 | if len(obj) > max_length: 57 | return obj[:max_length] + " ..." 58 | return obj 59 | else: 60 | return obj 61 | 62 | 63 | def pretty_json(data: dict, first_prefix: str, child_prefix: str, color: str) -> str: 64 | """ 65 | Convert a dictionary to a pretty-printed JSON string with prefixed indentation, 66 | applying the specified color to the entire JSON block. 67 | 68 | Args: 69 | data: The dictionary to convert. 70 | first_prefix: Prefix string for the first line (tree branch). 71 | child_prefix: Prefix string for subsequent lines (tree vertical continuation). 72 | color: The color name or hex code to apply. 73 | 74 | Returns: 75 | Indented JSON string with rich formatting. 76 | """ 77 | try: 78 | truncated_data = _truncate_long_strings(data, MAX_ARG_LENGTH) 79 | 80 | def _custom_json(obj, indent=0): 81 | INDENT = 2 82 | space = " " * (indent * INDENT) 83 | if isinstance(obj, dict): 84 | if not obj: 85 | return "{}" 86 | items = [] 87 | for k, v in obj.items(): 88 | key_str = json.dumps(k, ensure_ascii=False) 89 | if isinstance(v, list): 90 | # Inline list 91 | value_str = json.dumps(v, ensure_ascii=False) 92 | elif isinstance(v, dict): 93 | value_str = _custom_json(v, indent + 1) 94 | else: 95 | value_str = json.dumps(v, ensure_ascii=False) 96 | items.append( 97 | f"\n{' ' * ((indent + 1) * INDENT)}{key_str}: {value_str}" 98 | ) 99 | return "{" + ",".join(items) + f"\n{space}" + "}" 100 | elif isinstance(obj, list): 101 | # Top-level list (shouldn't happen for our use case, but handle anyway) 102 | return json.dumps(obj, ensure_ascii=False) 103 | else: 104 | return json.dumps(obj, ensure_ascii=False) 105 | 106 | json_str = _custom_json(truncated_data, 0) 107 | lines = json_str.splitlines() 108 | if not lines: 109 | return "" 110 | 111 | result_lines = [] 112 | for idx, line in enumerate(lines): 113 | # Apply color to each individual line 114 | colored_line = f"[{color}]{line}[/]" 115 | 116 | if idx == 0: 117 | result_lines.append(f"{first_prefix}{colored_line}") 118 | else: 119 | leading_spaces = len(line) - len(line.lstrip(" ")) 120 | result_lines.append( 121 | f"{child_prefix}{' ' * leading_spaces}{colored_line.lstrip(' ')}" 122 | ) 123 | 124 | return "\n".join(result_lines) 125 | except TypeError as e: 126 | return f"{first_prefix}{{... serialization error: {e} ...}}" 127 | except Exception as e: 128 | return f"{first_prefix}{{... unknown error during json dump: {e} ...}}" 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | pyhunt_logo 4 | 5 | # pyhunt 6 | 7 | `pyhunt` is a lightweight logging tool that visually represents logs for quick structural understanding and debugging. 8 | Simply add a decorator to your functions, and all logs are automatically traced and displayed in your terminal. 9 | 10 | [![PyPI version](https://img.shields.io/pypi/v/pyhunt.svg)](https://pypi.org/project/pyhunt/) 11 | [![Python Versions](https://img.shields.io/pypi/pyversions/pyhunt.svg)](https://pypi.org/project/pyhunt/) 12 | 13 | #### English | [한국어](/README_KR.md) 14 | 15 | --- 16 | 17 | https://github.com/user-attachments/assets/3d4389fe-4708-423a-812e-25f2e7200053 18 | 19 | pyhunt_description 20 | 21 |
22 | 23 | ## Features 24 | 25 | - **Automatic Function/Method Call Tracing**: Automatically records the flow of synchronous/asynchronous functions and classes with a single `@trace` decorator. 26 | - **Rich Colors and Tree-Structured Logs**: Enhances readability with color and indentation based on call depth. 27 | - **Multiple Log Levels Supported**: DEBUG, INFO, WARNING, ERROR, CRITICAL. 28 | - **Set Log Level via CLI**: Manage and store `HUNT_LEVEL` in a `.env` file. 29 | - **Optimized for AI Workflows**: Easily trace code generated by AI. 30 | - **Detailed Exception Information**: Includes call arguments, location, and stack trace on exceptions. 31 | 32 | ## Installation 33 | 34 | ### Install with pip 35 | ```bash 36 | pip install pyhunt 37 | ``` 38 | 39 | ### Install with uv 40 | ```bash 41 | uv add pyhunt 42 | ``` 43 | 44 | ## Quick Start 45 | 46 | ### 1. Set Up and Manage Environment Variable File 47 | You can set up and manage the `.env` file by running the `hunt` command. 48 | 49 | ```bash 50 | hunt 51 | ``` 52 | 53 | Executing the above command sets `HUNT_LEVEL=DEBUG` and `ROOT_DIR` to the current directory in the `.env` file. 54 | 55 | ### 2. Apply `@trace` to Functions or Classes 56 | See more examples in the [examples](https://github.com/pyhunt/pyhunt/tree/main/examples) folder. 57 | 58 | #### Basic Example 59 | ```py 60 | from pyhunt import trace 61 | 62 | @trace 63 | def test(value): 64 | return value 65 | ``` 66 | 67 | #### Asynchronous Function 68 | ```py 69 | @trace 70 | async def test(value): 71 | return value 72 | ``` 73 | 74 | #### Class 75 | ```py 76 | @trace 77 | class MyClass: 78 | def first_method(self, value): 79 | return value 80 | 81 | def second_method(self, value): 82 | return value 83 | ``` 84 | 85 | ## Using with AI 86 | 87 | ### Rule Setup 88 | Add the following rules to `AGENTS.md` or `CLAUDE.md`: 89 | 90 | ```md 91 | ## Logging Rules 92 | 93 | **Import:** Import the decorator with `from pyhunt import trace`. 94 | **Tracing:** Use the `@trace` decorator to automatically log function calls and execution times. 95 | **Avoid `print()`:** Do not use the `print()` function. 96 | **Exception Handling:** Use `try`/`except Exception as e: raise e` blocks to maintain traceback. 97 | ``` 98 | 99 | ### Modifying Existing Codebase 100 | Prompt: **"Modify the code according to the logging rules."** 101 | 102 | ## Logger Usage 103 | 104 | The `logger` interface is recommended for use only in important sections. 105 | Most actions are traced via `@trace`, and excessive use may reduce readability. 106 | 107 | ```py 108 | from pyhunt import logger 109 | 110 | logger.debug("This is a debug log.") 111 | logger.info("This is an info log.") 112 | logger.warning("This is a warning log.") 113 | logger.error("This is an error log.") 114 | logger.critical("This is a critical log.") 115 | ``` 116 | 117 | ## CLI Usage 118 | 119 | You can manage log levels and other settings using the `hunt` command. 120 | 121 | ```bash 122 | hunt [options] 123 | ``` 124 | 125 | ### Supported Options 126 | 127 | - `--debug`, `--info`, `--warning`, `--error`, `--critical` : Set log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) 128 | ```bash 129 | hunt --debug # Most detailed logs 130 | hunt --info # Information logs 131 | hunt --warning # Warning logs 132 | hunt --error # Error logs 133 | hunt --critical # Critical logs only 134 | ``` 135 | 136 | - `--root` : Sets the `ROOT_DIR` environment variable to the current directory. 137 | ```bash 138 | hunt --root 139 | ``` 140 | 141 | - `--repeat ` : Sets the `HUNT_MAX_REPEAT` environment variable to the specified count. (Log repetition limit) 142 | ```bash 143 | hunt --repeat 5 144 | ``` 145 | 146 | - `--color ` : Enable or disable color output in logs. 147 | ```bash 148 | hunt --color false 149 | ``` 150 | 151 | - `--log-file ` : Set log file output. If no file is specified, defaults to `.pyhunt.log`. 152 | ```bash 153 | hunt --log-file 154 | ``` 155 | 156 | If no option is specified, the default is `DEBUG`. 157 | 158 | ### Environment Variables 159 | 160 | `pyhunt` supports the following environment variables through the `.env` file: 161 | 162 | - `HUNT_LEVEL`: Sets the log level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Default is `DEBUG`. 163 | - `HUNT_MAX_REPEAT`: The number of times the same log is displayed when repeated. Default is 3. 164 | - `ELAPSED`: Sets whether to display function execution time in logs (`True` or `False`). Default is `True`. 165 | - `HUNT_COLOR`: Sets whether to enable color output (`True` or `False`). Default is `True`. 166 | - `HUNT_LOG_FILE`: Sets the file path for log output. If not specified, logs are only displayed in the terminal. 167 | - `ROOT_DIR`: Sets the base directory for log output. Displays paths more accurately. 168 | -------------------------------------------------------------------------------- /pyhunt/console.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import os 4 | from pyhunt.config import COLOR_ENABLED 5 | 6 | 7 | class Console: 8 | """ 9 | Supports basic styles, hex colors (#RRGGBB, bg#RRGGBB), and OSC 8 hyperlinks. 10 | """ 11 | 12 | # --- Class Constants for ANSI/VT Codes --- 13 | _STYLE_CODES = { 14 | # Foregrounds (Basic) 15 | "black": "\033[30m", 16 | "red": "\033[31m", 17 | "green": "\033[32m", 18 | "yellow": "\033[33m", 19 | "blue": "\033[34m", 20 | "magenta": "\033[35m", 21 | "cyan": "\033[36m", 22 | "white": "\033[37m", 23 | # Backgrounds (Basic) 24 | "bgblack": "\033[40m", 25 | "bgred": "\033[41m", 26 | "bggreen": "\033[42m", 27 | "bgyellow": "\033[43m", 28 | "bgblue": "\033[44m", 29 | "bgmagenta": "\033[45m", 30 | "bgcyan": "\033[46m", 31 | "bgwhite": "\033[47m", 32 | # Attributes 33 | "bold": "\033[1m", 34 | "dim": "\033[2m", 35 | "italic": "\033[3m", 36 | "underline": "\033[4m", 37 | "blink": "\033[5m", 38 | "reverse": "\033[7m", 39 | "hidden": "\033[8m", 40 | "strikethrough": "\033[9m", 41 | } 42 | _RESET = "\033[0m" 43 | _OSC_8_START = "\033]8;;" 44 | _OSC_8_END = "\a" # BEL character, some terminals prefer \033\\ (ST) 45 | 46 | # --- Static Helper Method --- 47 | @staticmethod 48 | def _hex_to_ansi_rgb(hex_code, background=False): 49 | """Converts a hex color code (e.g., #RRGGBB) to an ANSI 24-bit color code.""" 50 | hex_code = hex_code.lstrip("#") 51 | if len(hex_code) != 6: 52 | return "" # Return empty string for invalid hex length 53 | try: 54 | r = int(hex_code[0:2], 16) 55 | g = int(hex_code[2:4], 16) 56 | b = int(hex_code[4:6], 16) 57 | except ValueError: 58 | return "" # Return empty string for invalid hex characters 59 | 60 | prefix = "\033[48;2;" if background else "\033[38;2;" 61 | return f"{prefix}{r};{g};{b}m" 62 | 63 | # --- Instance Methods --- 64 | def __init__(self, file=None): 65 | """ 66 | Initializes the Console instance. 67 | 68 | Args: 69 | file: The file object to write to. Defaults to sys.stdout. 70 | """ 71 | self._file = file if file is not None else sys.stdout 72 | 73 | def print(self, text, *, end="\n"): 74 | """ 75 | Parses text with markup and prints it directly to the console (self.file). 76 | 77 | Args: 78 | text (str): The text string containing markup. 79 | end (str, optional): String appended after the processed text. Defaults to "\\n". 80 | 81 | Markup examples: 82 | - [bold red]Text[/] 83 | - [#ff0000]Red Text[/] 84 | - [bg#00ff00 white]White on Green[/] 85 | - [underline link=https://example.com]Link[/] 86 | """ 87 | 88 | # If color is disabled, strip all markup 89 | if not COLOR_ENABLED: 90 | import re as re_module 91 | 92 | # Remove all markup tags [something]content[/] 93 | text = re_module.sub(r"\[[^\]]+\]", "", text) 94 | # Remove closing tags [/] or [/something] 95 | text = re_module.sub(r"\[/[^\]]*\]", "", text) 96 | # Print plain text without any formatting 97 | output_bytes = (text + end).encode() 98 | os.write(self._file.fileno(), output_bytes) 99 | return 100 | 101 | # Define the replacer function inside the print method 102 | # It can access class constants via Console._ GGG 103 | def replacer(match): 104 | attributes_str = match.group(1).strip() 105 | content = match.group(2) 106 | 107 | link = None 108 | active_codes = [] 109 | 110 | parts = attributes_str.split() 111 | for part in parts: 112 | lowered_part = part.lower() 113 | 114 | if lowered_part.startswith("link="): 115 | link = part[len("link=") :] 116 | elif lowered_part.startswith("#") and len(lowered_part) == 7: 117 | ansi_code = Console._hex_to_ansi_rgb(lowered_part, background=False) 118 | if ansi_code: 119 | active_codes.append(ansi_code) 120 | elif lowered_part.startswith("bg#") and len(lowered_part) == 9: 121 | ansi_code = Console._hex_to_ansi_rgb( 122 | lowered_part[2:], background=True 123 | ) 124 | if ansi_code: 125 | active_codes.append(ansi_code) 126 | elif lowered_part in Console._STYLE_CODES: 127 | active_codes.append(Console._STYLE_CODES[lowered_part]) 128 | 129 | style_sequence = "".join(active_codes) 130 | 131 | if link: 132 | formatted_content = f"{style_sequence}{content}{Console._RESET}" 133 | return f"{Console._OSC_8_START}{link}{Console._OSC_8_END}{formatted_content}{Console._OSC_8_START}{Console._OSC_8_END}" 134 | else: 135 | return f"{style_sequence}{content}{Console._RESET}" 136 | 137 | # The regex pattern (same as before) 138 | # Using Console._RESET ensures access to the class constant 139 | processed_text = re.sub( 140 | r"\[([^\]]+)\](.*?)\[\/.*?\]", replacer, text, flags=re.IGNORECASE 141 | ) 142 | 143 | # Print the final processed string to the instance's file 144 | # Use os.write to output bytes directly 145 | output_bytes = (processed_text + end).encode() 146 | os.write(self._file.fileno(), output_bytes) 147 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 3 3 | requires-python = ">=3.12" 4 | 5 | [[package]] 6 | name = "colorama" 7 | version = "0.4.6" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 12 | ] 13 | 14 | [[package]] 15 | name = "dotenv" 16 | version = "0.9.9" 17 | source = { registry = "https://pypi.org/simple" } 18 | dependencies = [ 19 | { name = "python-dotenv" }, 20 | ] 21 | wheels = [ 22 | { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, 23 | ] 24 | 25 | [[package]] 26 | name = "iniconfig" 27 | version = "2.1.0" 28 | source = { registry = "https://pypi.org/simple" } 29 | sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } 30 | wheels = [ 31 | { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, 32 | ] 33 | 34 | [[package]] 35 | name = "packaging" 36 | version = "24.2" 37 | source = { registry = "https://pypi.org/simple" } 38 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } 39 | wheels = [ 40 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, 41 | ] 42 | 43 | [[package]] 44 | name = "pluggy" 45 | version = "1.5.0" 46 | source = { registry = "https://pypi.org/simple" } 47 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } 48 | wheels = [ 49 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, 50 | ] 51 | 52 | [[package]] 53 | name = "pyhunt" 54 | version = "1.2.0" 55 | source = { editable = "." } 56 | dependencies = [ 57 | { name = "dotenv" }, 58 | ] 59 | 60 | [package.dev-dependencies] 61 | dev = [ 62 | { name = "pytest-asyncio" }, 63 | ] 64 | 65 | [package.metadata] 66 | requires-dist = [{ name = "dotenv", specifier = ">=0.9.9" }] 67 | 68 | [package.metadata.requires-dev] 69 | dev = [{ name = "pytest-asyncio" }] 70 | 71 | [[package]] 72 | name = "pytest" 73 | version = "8.3.5" 74 | source = { registry = "https://pypi.org/simple" } 75 | dependencies = [ 76 | { name = "colorama", marker = "sys_platform == 'win32'" }, 77 | { name = "iniconfig" }, 78 | { name = "packaging" }, 79 | { name = "pluggy" }, 80 | ] 81 | sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } 82 | wheels = [ 83 | { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, 84 | ] 85 | 86 | [[package]] 87 | name = "pytest-asyncio" 88 | version = "0.26.0" 89 | source = { registry = "https://pypi.org/simple" } 90 | dependencies = [ 91 | { name = "pytest" }, 92 | ] 93 | sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156, upload-time = "2025-03-25T06:22:28.883Z" } 94 | wheels = [ 95 | { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" }, 96 | ] 97 | 98 | [[package]] 99 | name = "python-dotenv" 100 | version = "1.1.0" 101 | source = { registry = "https://pypi.org/simple" } 102 | sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } 103 | wheels = [ 104 | { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, 105 | ] 106 | -------------------------------------------------------------------------------- /examples/outputs/type_example.txt: -------------------------------------------------------------------------------- 1 | ├─▶ 1 🟢 Entry main | examples/type_example.py:46 2 | │    ├─▶ 2 🟢 Entry add | examples/type_example.py:6 3 | │    │  { 4 | │    │   "a": 5, 5 | │    │   "b": 7 6 | │    │  } 7 | │    ├── 2 🔳 Exit add 8 | │    ├─▶ 2 🟢 Entry add | examples/type_example.py:6 9 | │    │  { 10 | │    │   "a": 3.5, 11 | │    │   "b": 2.1 12 | │    │  } 13 | │    ├── 2 🔳 Exit add 14 | │    ├─▶ 2 🟢 Entry concat_strings | examples/type_example.py:11 15 | │    │  { 16 | │    │   "s1": "Hello", 17 | │    │   "s2": "World", 18 | │    │   "sep": " " 19 | │    │  } 20 | │    ├── 2 🔳 Exit concat_strings 21 | │    ├─▶ 2 🟢 Entry concat_strings | examples/type_example.py:11 22 | │    │  { 23 | │    │   "s1": "Hello", 24 | │    │   "s2": "World", 25 | │    │   "sep": ", " 26 | │    │  } 27 | │    ├── 2 🔳 Exit concat_strings 28 | │    ├─▶ 2 🟢 Entry repeat_string | examples/type_example.py:16 29 | │    │  { 30 | │    │   "s": "abc", 31 | │    │   "times": 3 32 | │    │  } 33 | │    ├── 2 🔳 Exit repeat_string 34 | │    ├─▶ 2 🟢 Entry combine_data | examples/type_example.py:21 35 | │    │  { 36 | │    │   "num": 4, 37 | │    │   "text": "test", 38 | │    │   "flag": true 39 | │    │  } 40 | │    ├── 2 🔳 Exit combine_data 41 | │    ├─▶ 2 🟢 Entry combine_data | examples/type_example.py:21 42 | │    │  { 43 | │    │   "num": 10, 44 | │    │   "text": "example", 45 | │    │   "flag": false 46 | │    │  } 47 | │    ├── 2 🔳 Exit combine_data 48 | │    ├─▶ 2 🟢 Entry sum_list | examples/type_example.py:29 49 | │    │  { 50 | │    │   "numbers": [1, 2, 3, 4, 5] 51 | │    │  } 52 | │    ├── 2 🔳 Exit sum_list 53 | │    ├─▶ 2 🟢 Entry get_dict_keys | examples/type_example.py:34 54 | │    │  { 55 | │    │   "d": { 56 | │    │   "a": 1, 57 | │    │   "b": 2, 58 | │    │   "c": 3 59 | │    │   } 60 | │    │  } 61 | │    ├── 2 🔳 Exit get_dict_keys 62 | │    ├─▶ 2 🟢 Entry merge_dicts | examples/type_example.py:39 63 | │    │  { 64 | │    │   "d1": { 65 | │    │   "x": 1, 66 | │    │   "y": 2 67 | │    │   }, 68 | │    │   "d2": { 69 | │    │   "y": 3, 70 | │    │   "z": 4 71 | │    │   } 72 | │    │  } 73 | │    ├── 2 🔳 Exit merge_dicts 74 | │  1 INFO Results: 75 | │  1 INFO add(5, 7) = 12 76 | │  1 INFO add(3.5, 2.1) = 5.6 77 | │  1 INFO concat_strings("Hello", "World") = Hello World 78 | │  1 INFO concat_strings("Hello", "World", sep=", ") = Hello, World 79 | │  1 INFO repeat_string("abc", 3) = abcabcabc 80 | │  1 INFO combine_data(4, "test", True) = test repeated 4 times: testtesttesttest 81 | │  1 INFO combine_data(10, "example", False) = Number: 10, Text: example 82 | │  1 INFO sum_list([1, 2, 3, 4, 5]) = 15 83 | │  1 INFO get_dict_keys({"a": 1, "b": 2, "c": 3}) = ['a', 'b', 'c'] 84 | │  1 INFO merge_dicts({"x": 1, "y": 2}, {"y": 3, "z": 4}) = {'x': 1, 'y': 3, 'z': 4} 85 | ├── 1 🔳 Exit main 86 | -------------------------------------------------------------------------------- /pyhunt/decorator.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import inspect 3 | import os 4 | import sys 5 | import time 6 | import traceback 7 | from pathlib import Path 8 | from typing import Callable, List, Optional, Type, Union 9 | 10 | from pyhunt.config import LOG_LEVEL, ROOT_DIR 11 | from pyhunt.context import call_depth, current_function_context 12 | from pyhunt.helpers import format_call_args 13 | from pyhunt.logger import log_entry, log_error, log_exit, warning 14 | from pyhunt.utils import extract_first_traceback 15 | 16 | 17 | def trace( 18 | _obj: Optional[Union[Callable, Type]] = None, 19 | *, 20 | include_init: bool = False, 21 | exclude_methods: Optional[List[str]] = None, 22 | ) -> Union[Callable, Type]: 23 | """ 24 | Decorator to trace function or class method calls. 25 | 26 | Args: 27 | _obj: Function or class to decorate. 28 | include_init: If True, trace __init__ method (class decorator only). 29 | exclude_methods: List of method names to exclude (class decorator only). 30 | 31 | Returns: 32 | Decorated function or class. 33 | """ 34 | exclude = exclude_methods or [] 35 | 36 | def decorator(obj: Union[Callable, Type]) -> Union[Callable, Type]: 37 | # Disable tracing if global LOG_LEVEL is higher than debug 38 | if LOG_LEVEL != 10: 39 | return obj 40 | if isinstance(obj, type): 41 | # --- Class Decorator Logic (unchanged) --- 42 | cls = obj 43 | current_exclude = list(exclude) 44 | if not include_init: 45 | current_exclude.append("__init__") 46 | 47 | methods_to_trace = {} 48 | for base_cls in reversed(cls.__mro__): 49 | if base_cls is object: 50 | continue 51 | for name, method in base_cls.__dict__.items(): 52 | if name in current_exclude: 53 | continue 54 | is_special = name.startswith("__") and name.endswith("__") 55 | if is_special and name != "__init__": 56 | continue 57 | if name.startswith("_") and not is_special: 58 | continue 59 | if callable(method): 60 | if name in methods_to_trace: 61 | continue 62 | if inspect.isfunction(method) or inspect.iscoroutinefunction( 63 | method 64 | ): 65 | if name == "__init__" and include_init: 66 | methods_to_trace[name] = method 67 | elif name != "__init__": 68 | methods_to_trace[name] = method 69 | 70 | for name, method in methods_to_trace.items(): 71 | try: 72 | # Use the new _wrap_function which returns the appropriate wrapper 73 | traced_method = _wrap_function(method) 74 | setattr(cls, name, traced_method) 75 | except Exception as e: 76 | warning( 77 | None, f"Failed to apply trace to {cls.__name__}.{name}: {e}" 78 | ) 79 | return cls 80 | # --- End Class Decorator Logic --- 81 | 82 | elif callable(obj): 83 | # Apply wrapper to a single function 84 | return _wrap_function(obj) 85 | 86 | else: 87 | warning( 88 | None, 89 | f"Trace decorator applied to non-callable, non-class object: {type(obj)}", 90 | ) 91 | return obj 92 | 93 | def _wrap_function(func: Callable) -> Callable: 94 | func_name = func.__name__ 95 | is_async = inspect.iscoroutinefunction(func) 96 | 97 | class_name: Optional[str] = None 98 | try: 99 | qualname_parts = func.__qualname__.split(".") 100 | if len(qualname_parts) > 1: 101 | class_name = qualname_parts[-2] 102 | except AttributeError: 103 | pass 104 | 105 | # _invoke sets up context, calls original func, logs entry/error 106 | # It returns the result (for sync) or awaitable (for async) 107 | # It raises exceptions upward without filtering traceback 108 | def _invoke(is_async_func_flag, *args, **kwargs): 109 | parent_ctx = current_function_context.get() 110 | current_ctx_name = f"{class_name}.{func_name}" if class_name else func_name 111 | full_ctx = ( 112 | f"{parent_ctx} -> {current_ctx_name}" 113 | if parent_ctx 114 | else current_ctx_name 115 | ) 116 | token_ctx = current_function_context.set(full_ctx) 117 | 118 | parent_depth = call_depth.get() 119 | current_depth = parent_depth + 1 120 | token_depth = call_depth.set(current_depth) 121 | 122 | start = time.perf_counter() # Start time needed for error logging 123 | call_args = {} 124 | 125 | try: 126 | call_args = format_call_args(func, args, kwargs) 127 | 128 | # Location of the function 129 | try: 130 | filename = inspect.getfile(func) 131 | lines, lineno = inspect.getsourcelines(func) 132 | 133 | p = Path(filename) 134 | path_prefix = ( 135 | p.name 136 | if p.parent == Path(ROOT_DIR) 137 | else f"{p.parent.name}/{p.name}" 138 | ) 139 | line_offset = ( 140 | 0 if class_name is not None else 1 141 | ) # For class methods, do not add 1 142 | location = f"{path_prefix}:{lineno + line_offset}" 143 | except (TypeError, OSError): 144 | location = f"{p.parent.name}/{p.name}:{lineno}" 145 | 146 | log_entry( 147 | func_name, 148 | class_name, 149 | is_async_func_flag, 150 | call_args, 151 | location, 152 | current_depth, 153 | ) 154 | 155 | if is_async_func_flag: 156 | result = func(*args, **kwargs) # Get the awaitable 157 | if not inspect.isawaitable(result): 158 | raise TypeError( 159 | f"Expected awaitable from async func {func_name}, got {type(result)}" 160 | ) 161 | # For async, return tokens for context reset 162 | return result, token_ctx, token_depth 163 | else: 164 | result = func(*args, **kwargs) # Execute sync func 165 | return result 166 | 167 | except Exception as e: 168 | elapsed = time.perf_counter() - start 169 | if ( 170 | not call_args 171 | ): # Ensure args are captured even if formatting failed earlier 172 | try: 173 | call_args = format_call_args(func, args, kwargs) 174 | except Exception: 175 | call_args = { 176 | "args": repr(args), 177 | "kwargs": repr(kwargs), 178 | "error": "Failed to format arguments", 179 | } 180 | 181 | # Error traceback location 182 | tb = e.__traceback__ 183 | extracted_tb = traceback.extract_tb(tb) 184 | if extracted_tb: 185 | last_frame = extracted_tb[-1] 186 | try: 187 | p = Path(last_frame.filename) 188 | if p.parent == Path(ROOT_DIR): 189 | location = f"{p.name}:{last_frame.lineno}" 190 | else: 191 | location = f"{p.parent.name}/{p.name}:{last_frame.lineno}" 192 | except Exception: 193 | location = f"{last_frame.filename}:{last_frame.lineno}" 194 | 195 | log_error( 196 | func_name, 197 | class_name, 198 | is_async_func_flag, 199 | elapsed, 200 | e, 201 | call_args, 202 | location, 203 | current_depth, 204 | ) 205 | # Re-raise without filtering here 206 | raise e 207 | finally: 208 | # Only reset context for sync functions here 209 | if not is_async_func_flag: 210 | call_depth.reset(token_depth) 211 | current_function_context.reset(token_ctx) 212 | 213 | @functools.wraps(func) 214 | async def async_wrapper(*args, **kwargs): 215 | start_time = time.perf_counter() 216 | parent_depth = call_depth.get() # Need depth for exit log 217 | current_depth = parent_depth + 1 218 | # Prepare for context reset after await 219 | token_ctx = None 220 | token_depth = None 221 | try: 222 | # _invoke returns (awaitable, token_ctx, token_depth) 223 | awaitable, token_ctx, token_depth = _invoke(True, *args, **kwargs) 224 | result = await awaitable 225 | # Log exit after successful await 226 | elapsed = time.perf_counter() - start_time 227 | log_exit( 228 | func_name, 229 | class_name, 230 | True, # is_async 231 | elapsed, 232 | current_depth, 233 | ) 234 | return result 235 | except Exception as e: 236 | full_tb_str = "".join(traceback.format_exception(e)) 237 | first_tb_str = extract_first_traceback(full_tb_str) 238 | 239 | os.write(1, first_tb_str.encode()) 240 | sys.exit(1) 241 | finally: 242 | # Reset context after await completes 243 | if token_ctx is not None and token_depth is not None: 244 | call_depth.reset(token_depth) 245 | current_function_context.reset(token_ctx) 246 | 247 | @functools.wraps(func) 248 | def wrapper(*args, **kwargs): 249 | start_time = time.perf_counter() 250 | parent_depth = call_depth.get() # Need depth for exit log 251 | current_depth = parent_depth + 1 252 | try: 253 | # _invoke executes the function and returns result 254 | result = _invoke(False, *args, **kwargs) 255 | # Log exit after successful execution 256 | elapsed = time.perf_counter() - start_time 257 | log_exit( 258 | func_name, 259 | class_name, 260 | False, # is_async 261 | elapsed, 262 | current_depth, 263 | ) 264 | return result 265 | except Exception as e: 266 | full_tb_str = "".join(traceback.format_exception(e)) 267 | first_tb_str = extract_first_traceback(full_tb_str) 268 | 269 | os.write(1, first_tb_str.encode()) 270 | sys.exit(1) 271 | 272 | return async_wrapper if is_async else wrapper 273 | 274 | # Entry point for the decorator 275 | if _obj is None: 276 | # Called as @trace() or @trace(...) 277 | return decorator 278 | else: 279 | # Called as @trace 280 | return decorator(_obj) 281 | -------------------------------------------------------------------------------- /pyhunt/logger.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | import threading 3 | import atexit 4 | from pathlib import Path 5 | from datetime import datetime 6 | from pyhunt.console import Console 7 | from pyhunt.config import ( 8 | LOG_LEVEL, 9 | LOG_LEVELS, 10 | MAX_REPEAT, 11 | ELAPSED, 12 | LOG_FILE, 13 | COLOR_ENABLED, 14 | ) 15 | from pyhunt.colors import build_indent, get_color 16 | from pyhunt.context import call_depth 17 | from pyhunt.helpers import pretty_json 18 | 19 | 20 | class FileLogger: 21 | def __init__(self, filename: str = None): 22 | self.filename = filename 23 | self.file_path = Path(self.filename) if filename else None 24 | self.enabled = bool(filename) # Only enabled if filename is provided 25 | self.session_lines = [] 26 | self.session_started = False 27 | 28 | def _get_session_header(self) -> str: 29 | """ 30 | Get formatted session header with date and time. 31 | """ 32 | now = datetime.now() 33 | date_str = now.strftime("%Y-%m-%d") 34 | time_str = now.strftime("%H:%M:%S.%f")[:-3] # Format: 14:30:45.123 35 | return f"=== {date_str} {time_str} ===" 36 | 37 | def start_session(self): 38 | """ 39 | Start a new logging session. 40 | """ 41 | if not self.session_started: 42 | self.session_lines = [] 43 | self.session_started = True 44 | 45 | def end_session(self): 46 | """ 47 | End the current logging session and write all output as one block. 48 | """ 49 | if self.session_started and self.session_lines: 50 | try: 51 | # Read existing content 52 | existing_content = "" 53 | if self.file_path.exists(): 54 | with open(self.file_path, "r", encoding="utf-8") as f: 55 | existing_content = f.read() 56 | 57 | # Write new content at the beginning with session header 58 | with open(self.file_path, "w", encoding="utf-8") as f: 59 | # Add session header 60 | f.write(f"{self._get_session_header()}\n") 61 | # Add all session lines 62 | for line in self.session_lines: 63 | f.write(f"{line}\n") 64 | # Add separator 65 | f.write("\n") 66 | # Add existing content 67 | if existing_content: 68 | f.write(existing_content) 69 | 70 | # Reset session 71 | self.session_lines = [] 72 | self.session_started = False 73 | except Exception: 74 | # Silently fail if file logging fails 75 | pass 76 | 77 | def write(self, message: str): 78 | """ 79 | Add message lines to the current session. 80 | """ 81 | if not self.enabled: 82 | return 83 | 84 | # Split message into lines and add to session 85 | lines = message.split("\n") 86 | for line in lines: 87 | self.session_lines.append(line) 88 | 89 | def write_raw(self, message: str): 90 | """ 91 | Add raw message to the current session. 92 | """ 93 | if not self.enabled: 94 | return 95 | 96 | # Add message to session lines 97 | lines = message.split("\n") 98 | for line in lines: 99 | self.session_lines.append(line) 100 | 101 | def print_with_file_logging(self, message: str, console, end="\n"): 102 | """ 103 | Print message to console and also write to file (with markup stripped). 104 | """ 105 | console.print(message, end=end) 106 | clean_message = _strip_markup(message) 107 | 108 | # Add message to the current session 109 | if self.session_started: 110 | # Split clean message into lines and add to session 111 | lines = clean_message.split("\n") 112 | for line in lines: 113 | self.session_lines.append(line) 114 | 115 | 116 | # Initialize file logger 117 | # Initialize file logger only if LOG_FILE is set 118 | file_logger = FileLogger(LOG_FILE) if LOG_FILE else None 119 | 120 | # Start session when module is imported if file logger exists 121 | if file_logger: 122 | file_logger.start_session() 123 | # Register cleanup function to end session on exit 124 | atexit.register(file_logger.end_session) 125 | else: 126 | # Create a dummy file logger for type consistency 127 | file_logger = FileLogger(None) 128 | 129 | # Initialize Rich Console 130 | console = Console() 131 | 132 | 133 | # Helper function to get emoji or text replacement based on color setting 134 | def _get_emoji_or_text(color_emoji: str, text_replacement: str) -> str: 135 | """Return emoji if color is enabled, otherwise return text replacement.""" 136 | return color_emoji if COLOR_ENABLED else text_replacement 137 | 138 | 139 | # --- Log suppression mechanism --- 140 | _log_count_map = {} 141 | _log_count_lock = threading.Lock() 142 | 143 | 144 | def _strip_markup(text: str) -> str: 145 | """ 146 | Remove all markup tags from text for clean file output. 147 | """ 148 | import re as re_module 149 | 150 | # Remove all markup tags [something]content[/] 151 | text = re_module.sub(r"\[[^\]]+\]", "", text) 152 | # Remove closing tags [/] or [/something] 153 | text = re_module.sub(r"\[/[^\]]*\]", "", text) 154 | return text 155 | 156 | 157 | def _write_to_file(message: str) -> None: 158 | """ 159 | Write message to log file with markup stripped. 160 | """ 161 | clean_message = _strip_markup(message) 162 | file_logger.write(clean_message) 163 | 164 | 165 | def _should_suppress_log(key): 166 | """Returns True if the log for this key should be suppressed, False otherwise. 167 | If suppression is triggered, returns a tuple (suppress, show_ellipsis) where: 168 | - suppress: True if log should be suppressed 169 | - show_ellipsis: True if "... 생략 ..." should be shown (only once per key) 170 | """ 171 | if MAX_REPEAT is None or MAX_REPEAT < 1: 172 | return False, False 173 | with _log_count_lock: 174 | count = _log_count_map.get(key, 0) 175 | if count < MAX_REPEAT: 176 | _log_count_map[key] = count + 1 177 | return False, False 178 | elif count == MAX_REPEAT: 179 | _log_count_map[key] = count + 1 180 | return True, True # Suppress log, but show ellipsis 181 | else: 182 | return True, False # Suppress log, no ellipsis 183 | 184 | 185 | # --- End log suppression mechanism --- 186 | 187 | 188 | def _format_truncation_message(event_type, depth): 189 | color = "#808080" 190 | msg = f"[{color}] ... Repeated logs have been omitted | MAX_REPEAT: {MAX_REPEAT}[/]" 191 | return format_with_tree_indent(msg, depth, event_type) 192 | 193 | 194 | def should_log(level_name: str) -> bool: 195 | """ 196 | Determine if a message at the given level should be logged. 197 | """ 198 | level_value = LOG_LEVELS.get(level_name.lower(), 20) 199 | return level_value >= LOG_LEVEL 200 | 201 | 202 | def format_with_tree_indent(message: str, depth: int, event_type: str) -> str: 203 | """ 204 | Apply tree indentation and prefix symbols to a multi-line log message. 205 | 206 | Args: 207 | message: The pure log message without indentation. 208 | depth: The call depth for indentation. 209 | event_type: One of 'entry', 'exit', 'error'. 210 | 211 | Returns: 212 | The message decorated with tree indentation and symbols. 213 | """ 214 | 215 | color = get_color(depth) 216 | indent = build_indent(depth) 217 | 218 | # Determine prefix symbols based on event type 219 | if event_type == "entry": 220 | first_prefix = f"{indent}[{color}]├─▶[/] " 221 | child_prefix = f"{indent}[{color}]│ [/] " 222 | elif event_type == "exit": 223 | first_prefix = f"{indent}[{color}]├──[/] " 224 | child_prefix = f"{indent}[{color}]│ [/] " 225 | elif event_type == "error": 226 | first_prefix = f"{indent}[{color}]└──[/] " 227 | child_prefix = f"{indent}[{color}]│ [/] " 228 | else: 229 | first_prefix = f"{indent}[{color}]│ [/] " 230 | child_prefix = f"{indent}[{color}]│ [/] " 231 | 232 | # Apply child prefix to log messages, filtering empty lines 233 | lines = message.splitlines() 234 | if not lines: 235 | return "" 236 | 237 | decorated_lines = [f"{first_prefix}{lines[0]}"] 238 | decorated_lines += [f"{child_prefix}{line}" for line in lines[1:]] 239 | return "\n".join(decorated_lines) 240 | 241 | 242 | def log_entry( 243 | func_name: str, 244 | class_name: Optional[str], 245 | is_async: bool, 246 | call_args: Dict[str, Any], 247 | location: str, 248 | depth: int, 249 | ) -> None: 250 | color = get_color(depth) 251 | 252 | sync_async = "async " if is_async else "" 253 | name = f"{class_name}.{func_name}" if class_name else func_name 254 | depth_str = f"[{color}]{depth}[/]" 255 | colored_name = f"[bold {color}]{name}[/]" 256 | colored_location = f"[bold {color}]{location}[/]" 257 | 258 | # Suppression key: (event_type, func_name, class_name, location) 259 | suppress_key = ("entry", func_name, class_name, location) 260 | suppress, show_trunc = _should_suppress_log(suppress_key) 261 | if suppress: 262 | if show_trunc: 263 | trunc_msg = _format_truncation_message("entry", depth) 264 | try: 265 | if should_log("debug"): 266 | file_logger.print_with_file_logging(trunc_msg, console) 267 | except Exception: 268 | pass 269 | return 270 | 271 | args_to_format = {k: v for k, v in call_args.items() if k != "self"} 272 | if not args_to_format: 273 | args_json_str = "" 274 | else: 275 | args_json_str = pretty_json(args_to_format, "", "", color) 276 | 277 | entry_indicator = _get_emoji_or_text("🟢", "→") 278 | core_parts = [ 279 | f"{depth_str} {entry_indicator} Entry {sync_async}{colored_name} | {colored_location}", 280 | args_json_str, 281 | ] 282 | core_message = "\n".join(m for m in core_parts if m and m.strip()) 283 | message = format_with_tree_indent(core_message, depth, "entry") 284 | 285 | try: 286 | if should_log("debug"): 287 | file_logger.print_with_file_logging(message, console) 288 | except Exception as e: 289 | file_logger.print_with_file_logging( 290 | f"[bold red]Error during logging for {name}: {e}[/]", console 291 | ) 292 | 293 | 294 | def log_exit( 295 | func_name: str, 296 | class_name: Optional[str], 297 | is_async: bool, 298 | elapsed: float, 299 | depth: int, 300 | ) -> None: 301 | color = get_color(depth) 302 | 303 | sync_async = "async " if is_async else "" 304 | name = f"{class_name}.{func_name}" if class_name else func_name 305 | depth_str = f"[{color}]{depth}[/]" 306 | colored_name = f"[{color}]{name}[/]" 307 | 308 | # Suppression key: (event_type, func_name, class_name) 309 | suppress_key = ("exit", func_name, class_name) 310 | suppress, _ = _should_suppress_log(suppress_key) 311 | if suppress: 312 | return 313 | 314 | elapsed_str = f" | {elapsed:.4f}s" if ELAPSED else "" 315 | exit_indicator = _get_emoji_or_text("🔳", "←") 316 | core_message = ( 317 | f"{depth_str} {exit_indicator} Exit {sync_async}{colored_name}{elapsed_str}" 318 | ) 319 | message = format_with_tree_indent(core_message, depth, "exit") 320 | 321 | try: 322 | if should_log("debug"): 323 | file_logger.print_with_file_logging(message, console) 324 | except Exception as e: 325 | file_logger.print_with_file_logging( 326 | f"[bold red]Error during logging for {name}: {e}[/]", console 327 | ) 328 | 329 | 330 | def log_error( 331 | func_name: str, 332 | class_name: Optional[str], 333 | is_async: bool, 334 | elapsed: float, 335 | exception: Exception, 336 | call_args: Dict[str, Any], 337 | location: str, 338 | depth: int, 339 | ) -> None: 340 | color = get_color(depth) 341 | 342 | sync_async = "async " if is_async else "" 343 | name = f"{class_name}.{func_name}" if class_name else func_name 344 | depth_str = f"[{color}]{depth}[/]" 345 | colored_name = f"[bold {color}]{name}[/]" 346 | colored_location = f"[bold {color}]{location}[/]" 347 | 348 | # Suppression key: (event_type, func_name, class_name, precise_location) 349 | suppress_key = ("error", func_name, class_name, location) 350 | suppress, show_trunc = _should_suppress_log(suppress_key) 351 | if suppress: 352 | if show_trunc: 353 | trunc_msg = _format_truncation_message("error", depth) 354 | try: 355 | if should_log("debug"): 356 | file_logger.print_with_file_logging(trunc_msg, console) 357 | except Exception: 358 | pass 359 | return 360 | 361 | args_to_format = {k: v for k, v in call_args.items() if k != "self"} 362 | if not args_to_format: 363 | args_json_str = "" 364 | else: 365 | args_json_str = pretty_json(args_to_format, "", "", color) 366 | 367 | error_indicator = _get_emoji_or_text("🟥", "!") 368 | core_parts = [ 369 | f"{depth_str} {error_indicator} Error {sync_async} {colored_name} | {colored_location}{f' | {elapsed:.4f}s' if ELAPSED else ''}", 370 | f"[bold #E32636]{type(exception).__name__}: {exception}[/]", 371 | args_json_str, 372 | ] 373 | core_message = "\n".join(m for m in core_parts if m and m.strip()) 374 | message = format_with_tree_indent(core_message, depth, "error") 375 | 376 | try: 377 | if should_log("debug"): 378 | file_logger.print_with_file_logging(message, console) 379 | except Exception as e: 380 | file_logger.print_with_file_logging( 381 | f"[bold red]Error during error logging for {name}: {e}[/]", console 382 | ) 383 | 384 | 385 | def styled_log(level_name: str, message: str, depth: int = 0) -> None: 386 | color = get_color(depth) 387 | depth_str = f" [{color}]{depth}[/]" 388 | 389 | # Suppression key: (level_name, message) 390 | suppress_key = ("styled", level_name, message) 391 | suppress, show_trunc = _should_suppress_log(suppress_key) 392 | if suppress: 393 | if show_trunc: 394 | trunc_msg = _format_truncation_message("", depth) 395 | if should_log(level_name): 396 | file_logger.print_with_file_logging(trunc_msg, console) 397 | return 398 | 399 | if level_name.lower() in ("debug", "info"): 400 | label = f"[cyan]{level_name.upper()}[/cyan]" 401 | elif level_name.lower() == "warning": 402 | label = f"[yellow]{level_name.upper()}[/yellow]" 403 | elif level_name.lower() in ("error", "critical"): 404 | label = f"[bold red]{level_name.upper()}[/bold red]" 405 | else: 406 | label = level_name.upper() 407 | 408 | core_message = f"{depth_str} {label} {message}" 409 | formatted_message = format_with_tree_indent(core_message, depth, "") 410 | if should_log(level_name): 411 | file_logger.print_with_file_logging(formatted_message, console) 412 | # _write_to_file(formatted_message) # Already handled by print_with_file_logging 413 | 414 | 415 | def debug(message: str, *args, **kwargs) -> None: 416 | current_depth = 0 417 | try: 418 | current_depth = call_depth.get() 419 | except LookupError: 420 | pass 421 | styled_log("debug", message, current_depth) 422 | 423 | 424 | def info(message: str, *args, **kwargs) -> None: 425 | current_depth = 0 426 | try: 427 | current_depth = call_depth.get() 428 | except LookupError: 429 | pass 430 | styled_log("info", message, current_depth) 431 | 432 | 433 | def warning(message: str, *args, **kwargs) -> None: 434 | current_depth = 0 435 | try: 436 | current_depth = call_depth.get() 437 | except LookupError: 438 | pass 439 | styled_log("warning", message, current_depth) 440 | 441 | 442 | def critical(message: str, *args, **kwargs) -> None: 443 | current_depth = 0 444 | try: 445 | current_depth = call_depth.get() 446 | except LookupError: 447 | pass 448 | styled_log("critical", message, current_depth) 449 | --------------------------------------------------------------------------------