mcp-coder-utils 0.1.2__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/settings.local.json +27 -20
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/PKG-INFO +1 -1
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/docs/architecture/architecture.md +1 -0
- mcp_coder_utils-0.1.3/src/mcp_coder_utils/log_utils.py +476 -0
- mcp_coder_utils-0.1.3/src/mcp_coder_utils/redaction.py +94 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils.egg-info/PKG-INFO +1 -1
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils.egg-info/SOURCES.txt +4 -0
- mcp_coder_utils-0.1.3/tests/test_log_utils.py +789 -0
- mcp_coder_utils-0.1.3/tests/test_redaction.py +225 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/vulture_whitelist.py +8 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/CLAUDE.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/agents/commit-pusher.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/knowledge_base/planning_principles.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/knowledge_base/python.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/knowledge_base/refactoring_principles.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/knowledge_base/software_engineering_principles.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/check_branch_status/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/commit_push/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/discuss/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implement_direct/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implementation_approve/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implementation_finalise/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implementation_needs_rework/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implementation_new_tasks/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implementation_review/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implementation_review_supervisor/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/issue_analyse/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/issue_approve/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/issue_create/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/issue_requirements/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/issue_update/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/plan_approve/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/plan_review/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/plan_review_supervisor/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/plan_update/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/rebase/SKILL.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/rebase/rebase_design.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.gitattributes +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.github/dependabot.yml +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.github/workflows/approve-command.yml +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.github/workflows/ci.yml +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.github/workflows/label-new-issues.yml +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.github/workflows/publish.yml +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.gitignore +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.importlinter +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.large-files-allowlist +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.mcp.json +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.python-version +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/LICENSE +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/README.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/claude.bat +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/claude_local.bat +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/pyproject.toml +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/setup.cfg +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils/__init__.py +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils/py.typed +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils/subprocess_runner.py +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils/subprocess_streaming.py +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils.egg-info/dependency_links.txt +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils.egg-info/requires.txt +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils.egg-info/top_level.txt +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tests/__init__.py +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tests/test_subprocess_runner.py +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tests/test_subprocess_runner_real.py +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tests/test_subprocess_streaming.py +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/todo_issues.md +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tools/read_github_deps.py +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tools/reinstall_local.bat +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tools/ruff_check.bat +0 -0
- {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tools/ruff_check.sh +0 -0
|
@@ -4,15 +4,6 @@
|
|
|
4
4
|
"mcp__tools-py__run_pylint_check",
|
|
5
5
|
"mcp__tools-py__run_pytest_check",
|
|
6
6
|
"mcp__tools-py__run_mypy_check",
|
|
7
|
-
"mcp__tools-py__run_lint_imports_check",
|
|
8
|
-
"mcp__tools-py__run_vulture_check",
|
|
9
|
-
"mcp__tools-py__run_format_code",
|
|
10
|
-
"mcp__tools-py__list_symbols",
|
|
11
|
-
"mcp__tools-py__find_references",
|
|
12
|
-
"mcp__tools-py__move_symbol",
|
|
13
|
-
"mcp__tools-py__rename_symbol",
|
|
14
|
-
"mcp__tools-py__move_module",
|
|
15
|
-
"mcp__tools-py__get_library_source",
|
|
16
7
|
"mcp__workspace__get_reference_projects",
|
|
17
8
|
"mcp__workspace__list_reference_directory",
|
|
18
9
|
"mcp__workspace__read_reference_file",
|
|
@@ -23,20 +14,15 @@
|
|
|
23
14
|
"mcp__workspace__delete_this_file",
|
|
24
15
|
"mcp__workspace__move_file",
|
|
25
16
|
"mcp__workspace__edit_file",
|
|
26
|
-
"Bash(git status:*)",
|
|
27
17
|
"Bash(git diff:*)",
|
|
28
|
-
"Bash(git log:*)",
|
|
29
|
-
"Bash(git fetch:*)",
|
|
30
|
-
"Bash(git ls-tree:*)",
|
|
31
18
|
"Bash(gh run view:*)",
|
|
32
19
|
"Bash(gh issue view:*)",
|
|
33
|
-
"Bash(
|
|
34
|
-
"Bash(
|
|
35
|
-
"Bash(
|
|
36
|
-
"Bash(
|
|
37
|
-
"Bash(
|
|
38
|
-
"Bash(
|
|
39
|
-
"Bash(ruff check:*)",
|
|
20
|
+
"Bash(./tools/tach_check.sh:*)",
|
|
21
|
+
"Bash(tach check:*)",
|
|
22
|
+
"Bash(start docs/architecture/dependency_graph.html)",
|
|
23
|
+
"Bash(./tools/pycycle_check.sh:*)",
|
|
24
|
+
"Bash(python -m pytest:*)",
|
|
25
|
+
"Bash(./tools/ruff_check.sh:*)",
|
|
40
26
|
"Skill(commit_push)",
|
|
41
27
|
"Skill(discuss)",
|
|
42
28
|
"Skill(implementation_approve)",
|
|
@@ -54,9 +40,30 @@
|
|
|
54
40
|
"Skill(plan_review_supervisor)",
|
|
55
41
|
"Skill(plan_update)",
|
|
56
42
|
"Skill(rebase)",
|
|
43
|
+
"Bash(find:*)",
|
|
44
|
+
"Bash(git status:*)",
|
|
45
|
+
"Bash(git log:*)",
|
|
46
|
+
"Bash(git fetch:*)",
|
|
47
|
+
"Bash(mcp-coder check file-size:*)",
|
|
48
|
+
"Bash(mcp-coder check branch-status:*)",
|
|
57
49
|
"Skill(check_branch_status)",
|
|
58
50
|
"Skill(implement_direct)",
|
|
51
|
+
"Skill(issue_requirements)",
|
|
59
52
|
"WebFetch(domain:*)",
|
|
53
|
+
"Bash(mcp-coder gh-tool:*)",
|
|
54
|
+
"Bash(git ls-tree:*)",
|
|
55
|
+
"Bash(mcp-coder git-tool:*)",
|
|
56
|
+
"Bash(ruff rule:*)",
|
|
57
|
+
"Bash(ruff check:*)",
|
|
58
|
+
"mcp__tools-py__list_symbols",
|
|
59
|
+
"mcp__tools-py__find_references",
|
|
60
|
+
"mcp__tools-py__move_symbol",
|
|
61
|
+
"mcp__tools-py__rename_symbol",
|
|
62
|
+
"mcp__tools-py__move_module",
|
|
63
|
+
"mcp__tools-py__run_format_code",
|
|
64
|
+
"mcp__tools-py__run_lint_imports_check",
|
|
65
|
+
"mcp__tools-py__run_vulture_check",
|
|
66
|
+
"mcp__tools-py__get_library_source",
|
|
60
67
|
"mcp__workspace__search_files"
|
|
61
68
|
]
|
|
62
69
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-coder-utils
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Shared low-level Python helpers (subprocess, logging, fs) for the mcp-coder family of repos
|
|
5
5
|
Author-email: Marcus Jellinghaus <Marcus@Jellinghaus.ch>
|
|
6
6
|
Project-URL: Homepage, https://github.com/MarcusJellinghaus/mcp-coder-utils
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""Shared logging configuration and utilities."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from functools import wraps
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, Optional, TypeVar, cast, overload
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
from pythonjsonlogger.json import JsonFormatter
|
|
13
|
+
|
|
14
|
+
from mcp_coder_utils.redaction import RedactableDict, redact_for_logging
|
|
15
|
+
|
|
16
|
+
__all__ = ["OUTPUT", "log_function_call", "setup_logging"]
|
|
17
|
+
|
|
18
|
+
# Custom OUTPUT log level (between INFO=20 and WARNING=30)
|
|
19
|
+
# OUTPUT is the default CLI threshold. At this threshold, CleanFormatter
|
|
20
|
+
# produces bare messages; at INFO/DEBUG, ExtraFieldsFormatter produces
|
|
21
|
+
# verbose timestamped output. Use logger.log(OUTPUT, ...) for user-facing
|
|
22
|
+
# CLI messages that should be clean at default verbosity.
|
|
23
|
+
OUTPUT = 25
|
|
24
|
+
logging.addLevelName(OUTPUT, "OUTPUT")
|
|
25
|
+
|
|
26
|
+
# Type variable for function return types
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
# Standard LogRecord fields to exclude when extracting extra fields
|
|
30
|
+
# These are built-in attributes of logging.LogRecord that should not be treated as "extra" data
|
|
31
|
+
STANDARD_LOG_FIELDS: frozenset[str] = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"name",
|
|
34
|
+
"msg",
|
|
35
|
+
"args",
|
|
36
|
+
"asctime", # Added by Formatter.format()
|
|
37
|
+
"created",
|
|
38
|
+
"filename",
|
|
39
|
+
"funcName",
|
|
40
|
+
"levelname",
|
|
41
|
+
"levelno",
|
|
42
|
+
"lineno",
|
|
43
|
+
"module",
|
|
44
|
+
"msecs",
|
|
45
|
+
"pathname",
|
|
46
|
+
"process",
|
|
47
|
+
"processName",
|
|
48
|
+
"relativeCreated",
|
|
49
|
+
"stack_info",
|
|
50
|
+
"exc_info",
|
|
51
|
+
"exc_text",
|
|
52
|
+
"thread",
|
|
53
|
+
"threadName",
|
|
54
|
+
"taskName",
|
|
55
|
+
"message",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CleanFormatter(logging.Formatter):
|
|
61
|
+
"""Formatter for clean CLI output.
|
|
62
|
+
|
|
63
|
+
Used when log threshold is OUTPUT. Produces:
|
|
64
|
+
- OUTPUT-level records: bare message (no prefix)
|
|
65
|
+
- WARNING/ERROR/CRITICAL: "LEVEL: message"
|
|
66
|
+
|
|
67
|
+
Extra fields (passed via extra={}) are appended as JSON,
|
|
68
|
+
independently of ExtraFieldsFormatter.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
72
|
+
"""Format the log record for clean CLI output.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
record: The log record to format.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The formatted log message.
|
|
79
|
+
"""
|
|
80
|
+
message = record.getMessage()
|
|
81
|
+
|
|
82
|
+
if record.levelno > OUTPUT:
|
|
83
|
+
message = f"{record.levelname}: {message}"
|
|
84
|
+
|
|
85
|
+
# Extract extra fields (attributes not in standard LogRecord fields)
|
|
86
|
+
extra_fields = {
|
|
87
|
+
key: value
|
|
88
|
+
for key, value in record.__dict__.items()
|
|
89
|
+
if key not in STANDARD_LOG_FIELDS
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if extra_fields:
|
|
93
|
+
suffix = json.dumps(extra_fields, default=str)
|
|
94
|
+
return f"{message} {suffix}"
|
|
95
|
+
|
|
96
|
+
return message
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ExtraFieldsFormatter(logging.Formatter):
|
|
100
|
+
"""Formatter that appends extra fields to log messages.
|
|
101
|
+
|
|
102
|
+
This formatter extends the standard logging.Formatter to include any
|
|
103
|
+
extra fields passed via the `extra` parameter in logging calls.
|
|
104
|
+
Extra fields are appended to the log message as a JSON object.
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
>>> formatter = ExtraFieldsFormatter(
|
|
108
|
+
... "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
109
|
+
... )
|
|
110
|
+
>>> handler.setFormatter(formatter)
|
|
111
|
+
>>> logger.info("User logged in", extra={"user_id": 123, "ip": "192.168.1.1"})
|
|
112
|
+
# Output: 2024-01-15 10:30:00 - myapp - INFO - User logged in {"user_id": 123, "ip": "192.168.1.1"}
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
116
|
+
"""Format the log record, appending any extra fields as JSON.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
record: The log record to format.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The formatted log message with extra fields appended as JSON.
|
|
123
|
+
"""
|
|
124
|
+
# Get the base formatted message
|
|
125
|
+
base_message = super().format(record)
|
|
126
|
+
|
|
127
|
+
# Extract extra fields (attributes not in standard LogRecord fields)
|
|
128
|
+
extra_fields = {
|
|
129
|
+
key: value
|
|
130
|
+
for key, value in record.__dict__.items()
|
|
131
|
+
if key not in STANDARD_LOG_FIELDS
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# If there are extra fields, append them as JSON
|
|
135
|
+
if extra_fields:
|
|
136
|
+
# Use default=str to handle non-serializable values
|
|
137
|
+
suffix = json.dumps(extra_fields, default=str)
|
|
138
|
+
return f"{base_message} {suffix}"
|
|
139
|
+
|
|
140
|
+
return base_message
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Create standard logger
|
|
144
|
+
stdlogger = logging.getLogger(__name__)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_testing_environment() -> bool:
|
|
148
|
+
"""Check if we're currently running in a testing environment (pytest).
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
True if pytest is detected in the current process.
|
|
152
|
+
"""
|
|
153
|
+
import sys
|
|
154
|
+
|
|
155
|
+
# Check if pytest is running
|
|
156
|
+
return (
|
|
157
|
+
"pytest" in sys.modules
|
|
158
|
+
or "_pytest" in sys.modules
|
|
159
|
+
or hasattr(sys, "_called_from_test")
|
|
160
|
+
or "PYTEST_CURRENT_TEST" in os.environ
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def setup_logging(log_level: str, log_file: Optional[str] = None) -> None:
|
|
165
|
+
"""Configure logging - if log_file specified, logs only to file; otherwise to console.
|
|
166
|
+
|
|
167
|
+
Configures structlog globally. Call once at startup;
|
|
168
|
+
repeated calls override the structlog configuration.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
ValueError: If log_level is not a valid logging level.
|
|
172
|
+
"""
|
|
173
|
+
# Set log level
|
|
174
|
+
numeric_level = getattr(logging, log_level.upper(), None)
|
|
175
|
+
if not isinstance(numeric_level, int):
|
|
176
|
+
numeric_level = logging.getLevelName(log_level.upper())
|
|
177
|
+
if not isinstance(numeric_level, int):
|
|
178
|
+
raise ValueError(f"Invalid log level: {log_level}")
|
|
179
|
+
|
|
180
|
+
# Don't clear handlers if we're in a testing environment (pytest)
|
|
181
|
+
# This prevents conflicts with pytest's logging capture
|
|
182
|
+
if not _is_testing_environment():
|
|
183
|
+
# Clear existing handlers
|
|
184
|
+
root_logger = logging.getLogger()
|
|
185
|
+
for handler in root_logger.handlers[:]:
|
|
186
|
+
root_logger.removeHandler(handler)
|
|
187
|
+
else:
|
|
188
|
+
root_logger = logging.getLogger()
|
|
189
|
+
|
|
190
|
+
root_logger.setLevel(numeric_level)
|
|
191
|
+
|
|
192
|
+
# Set up logging based on whether log_file is specified
|
|
193
|
+
if log_file:
|
|
194
|
+
# FILE LOGGING ONLY - no console output
|
|
195
|
+
# Create directory if needed
|
|
196
|
+
os.makedirs(os.path.dirname(os.path.abspath(log_file)), exist_ok=True)
|
|
197
|
+
|
|
198
|
+
# In testing environment, only add handler if it doesn't already exist
|
|
199
|
+
if _is_testing_environment():
|
|
200
|
+
# Check if file handler for this file already exists
|
|
201
|
+
file_handler_exists = any(
|
|
202
|
+
isinstance(h, logging.FileHandler)
|
|
203
|
+
and h.baseFilename == os.path.abspath(log_file)
|
|
204
|
+
for h in root_logger.handlers
|
|
205
|
+
)
|
|
206
|
+
if file_handler_exists:
|
|
207
|
+
return # Handler already exists, don't add another
|
|
208
|
+
|
|
209
|
+
# Configure JSON file handler
|
|
210
|
+
json_handler = logging.FileHandler(log_file)
|
|
211
|
+
json_handler.setLevel(numeric_level)
|
|
212
|
+
|
|
213
|
+
# This formatter ensures timestamp and level are included as separate fields in JSON
|
|
214
|
+
json_formatter = JsonFormatter(
|
|
215
|
+
fmt="%(asctime)s %(levelname)s %(name)s %(message)s %(module)s %(funcName)s %(lineno)d"
|
|
216
|
+
)
|
|
217
|
+
json_handler.setFormatter(json_formatter)
|
|
218
|
+
root_logger.addHandler(json_handler)
|
|
219
|
+
|
|
220
|
+
# Configure structlog processors for file logging
|
|
221
|
+
# Only configure if not in testing environment to avoid conflicts
|
|
222
|
+
if not _is_testing_environment():
|
|
223
|
+
structlog.configure(
|
|
224
|
+
processors=[
|
|
225
|
+
structlog.stdlib.filter_by_level,
|
|
226
|
+
structlog.stdlib.add_logger_name,
|
|
227
|
+
structlog.stdlib.add_log_level,
|
|
228
|
+
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"),
|
|
229
|
+
structlog.processors.StackInfoRenderer(),
|
|
230
|
+
structlog.processors.format_exc_info,
|
|
231
|
+
structlog.processors.UnicodeDecoder(),
|
|
232
|
+
structlog.processors.JSONRenderer(),
|
|
233
|
+
],
|
|
234
|
+
context_class=dict,
|
|
235
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
236
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
237
|
+
cache_logger_on_first_use=True,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Log initialization message to file only
|
|
241
|
+
stdlogger.info("Logging initialized: file=%s, level=%s", log_file, log_level)
|
|
242
|
+
else:
|
|
243
|
+
# CONSOLE LOGGING ONLY (fallback when no file specified)
|
|
244
|
+
# In testing environment, only add handler if no console handler exists
|
|
245
|
+
if _is_testing_environment():
|
|
246
|
+
console_handler_exists = any(
|
|
247
|
+
isinstance(h, logging.StreamHandler)
|
|
248
|
+
and not isinstance(h, logging.FileHandler)
|
|
249
|
+
for h in root_logger.handlers
|
|
250
|
+
)
|
|
251
|
+
if console_handler_exists:
|
|
252
|
+
return # Console handler already exists, don't add another
|
|
253
|
+
|
|
254
|
+
console_handler = logging.StreamHandler()
|
|
255
|
+
console_handler.setLevel(numeric_level)
|
|
256
|
+
if numeric_level >= OUTPUT:
|
|
257
|
+
console_formatter: logging.Formatter = CleanFormatter()
|
|
258
|
+
else:
|
|
259
|
+
console_formatter = ExtraFieldsFormatter(
|
|
260
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
261
|
+
)
|
|
262
|
+
console_handler.setFormatter(console_formatter)
|
|
263
|
+
root_logger.addHandler(console_handler)
|
|
264
|
+
|
|
265
|
+
# Configure structlog processors for console logging
|
|
266
|
+
# Only configure if not in testing environment to avoid conflicts
|
|
267
|
+
if not _is_testing_environment():
|
|
268
|
+
structlog.configure(
|
|
269
|
+
processors=[
|
|
270
|
+
structlog.stdlib.filter_by_level, # This will respect the logging level
|
|
271
|
+
structlog.stdlib.add_logger_name,
|
|
272
|
+
structlog.stdlib.add_log_level,
|
|
273
|
+
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"),
|
|
274
|
+
structlog.processors.StackInfoRenderer(),
|
|
275
|
+
structlog.processors.format_exc_info,
|
|
276
|
+
structlog.processors.UnicodeDecoder(),
|
|
277
|
+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
|
278
|
+
],
|
|
279
|
+
context_class=dict,
|
|
280
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
281
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
282
|
+
cache_logger_on_first_use=True,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
stdlogger.debug("Logging initialized: console=%s", log_level)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# Overload signatures for proper typing
|
|
289
|
+
@overload
|
|
290
|
+
def log_function_call(func: Callable[..., T]) -> Callable[..., T]: ...
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@overload
|
|
294
|
+
def log_function_call(
|
|
295
|
+
func: None = None,
|
|
296
|
+
*,
|
|
297
|
+
sensitive_fields: list[str] | None = None,
|
|
298
|
+
) -> Callable[[Callable[..., T]], Callable[..., T]]: ...
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def log_function_call(
|
|
302
|
+
func: Callable[..., T] | None = None,
|
|
303
|
+
*,
|
|
304
|
+
sensitive_fields: list[str] | None = None,
|
|
305
|
+
) -> Callable[..., T] | Callable[[Callable[..., T]], Callable[..., T]]:
|
|
306
|
+
"""Decorator to log function calls with parameters, timing, and results.
|
|
307
|
+
|
|
308
|
+
Can be used as @log_function_call or @log_function_call(sensitive_fields=[...]).
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
func: The function to decorate (when used without parentheses).
|
|
312
|
+
sensitive_fields: Optional list of field names whose values should be
|
|
313
|
+
redacted in logs. Applies to both parameters and return values.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Decorated function or decorator depending on usage.
|
|
317
|
+
"""
|
|
318
|
+
sensitive_set = set(sensitive_fields) if sensitive_fields else set()
|
|
319
|
+
|
|
320
|
+
def decorator(fn: Callable[..., T]) -> Callable[..., T]:
|
|
321
|
+
@wraps(fn)
|
|
322
|
+
def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
323
|
+
func_name = fn.__name__
|
|
324
|
+
module_name = fn.__module__
|
|
325
|
+
line_no = fn.__code__.co_firstlineno
|
|
326
|
+
|
|
327
|
+
# Get logger for the decorated function's module (not log_utils)
|
|
328
|
+
func_logger = logging.getLogger(module_name)
|
|
329
|
+
|
|
330
|
+
# Prepare parameters for logging
|
|
331
|
+
log_params: dict[str, Any] = {}
|
|
332
|
+
|
|
333
|
+
# Handle method calls (skip self/cls)
|
|
334
|
+
if args and fn.__code__.co_varnames[0] in ("self", "cls"):
|
|
335
|
+
log_params.update(
|
|
336
|
+
dict(zip(fn.__code__.co_varnames[1 : len(args)], args[1:]))
|
|
337
|
+
)
|
|
338
|
+
else:
|
|
339
|
+
log_params.update(dict(zip(fn.__code__.co_varnames[: len(args)], args)))
|
|
340
|
+
|
|
341
|
+
# Add keyword arguments
|
|
342
|
+
log_params.update(kwargs)
|
|
343
|
+
|
|
344
|
+
# Convert Path objects to strings and handle other non-serializable types
|
|
345
|
+
serializable_params: dict[str, Any] = {}
|
|
346
|
+
for k, v in log_params.items():
|
|
347
|
+
if isinstance(v, Path):
|
|
348
|
+
serializable_params[k] = str(v)
|
|
349
|
+
else:
|
|
350
|
+
try:
|
|
351
|
+
# Test if it's JSON serializable
|
|
352
|
+
json.dumps(v)
|
|
353
|
+
serializable_params[k] = v
|
|
354
|
+
except (TypeError, OverflowError):
|
|
355
|
+
# If not serializable, convert to string
|
|
356
|
+
serializable_params[k] = str(v)
|
|
357
|
+
|
|
358
|
+
# Apply redaction for sensitive fields
|
|
359
|
+
# Cast needed because serializable_params is dict[str, Any] but
|
|
360
|
+
# redact_for_logging accepts RedactableDict
|
|
361
|
+
params_for_log = (
|
|
362
|
+
redact_for_logging(
|
|
363
|
+
cast(RedactableDict, serializable_params),
|
|
364
|
+
sensitive_set,
|
|
365
|
+
)
|
|
366
|
+
if sensitive_set
|
|
367
|
+
else serializable_params
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# Check if structured logging is enabled
|
|
371
|
+
has_structured = any(
|
|
372
|
+
isinstance(h, logging.FileHandler) for h in logging.getLogger().handlers
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Log function call
|
|
376
|
+
if has_structured:
|
|
377
|
+
structlogger = structlog.get_logger(module_name)
|
|
378
|
+
structlogger.debug(
|
|
379
|
+
"Calling function",
|
|
380
|
+
function=func_name,
|
|
381
|
+
parameters=params_for_log,
|
|
382
|
+
module=module_name,
|
|
383
|
+
lineno=line_no,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
func_logger.debug(
|
|
387
|
+
"%s(%s)", func_name, json.dumps(params_for_log, default=str)
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Execute function and measure time
|
|
391
|
+
start_time = time.time()
|
|
392
|
+
try:
|
|
393
|
+
result = fn(*args, **kwargs)
|
|
394
|
+
elapsed_ms = round((time.time() - start_time) * 1000, 2)
|
|
395
|
+
|
|
396
|
+
# Prepare result for logging
|
|
397
|
+
result_for_log: Any
|
|
398
|
+
serializable_result: Any
|
|
399
|
+
if isinstance(result, (list, dict)) and len(str(result)) > 1000:
|
|
400
|
+
result_for_log = (
|
|
401
|
+
f"<Large result of type {type(result).__name__}, "
|
|
402
|
+
f"length: {len(str(result))}>"
|
|
403
|
+
)
|
|
404
|
+
serializable_result = result_for_log
|
|
405
|
+
else:
|
|
406
|
+
result_for_log = result
|
|
407
|
+
# Make result JSON serializable for structured logging
|
|
408
|
+
try:
|
|
409
|
+
json.dumps(result) # Test if result is JSON serializable
|
|
410
|
+
serializable_result = result
|
|
411
|
+
except (TypeError, OverflowError):
|
|
412
|
+
serializable_result = (
|
|
413
|
+
str(result) if result is not None else None
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Apply redaction to result if it's a dict
|
|
417
|
+
if sensitive_set and isinstance(serializable_result, dict):
|
|
418
|
+
serializable_result = redact_for_logging(
|
|
419
|
+
serializable_result, sensitive_set
|
|
420
|
+
)
|
|
421
|
+
if sensitive_set and isinstance(result_for_log, dict):
|
|
422
|
+
result_for_log = redact_for_logging(result_for_log, sensitive_set)
|
|
423
|
+
|
|
424
|
+
# Log completion
|
|
425
|
+
if has_structured:
|
|
426
|
+
structlogger.debug(
|
|
427
|
+
"Function completed",
|
|
428
|
+
function=func_name,
|
|
429
|
+
execution_time_ms=elapsed_ms,
|
|
430
|
+
status="success",
|
|
431
|
+
result=serializable_result,
|
|
432
|
+
module=module_name,
|
|
433
|
+
lineno=line_no,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
func_logger.debug(
|
|
437
|
+
"%s -> %s (%sms)", func_name, result_for_log, elapsed_ms
|
|
438
|
+
)
|
|
439
|
+
return result
|
|
440
|
+
|
|
441
|
+
except (
|
|
442
|
+
Exception
|
|
443
|
+
) as e: # pylint: disable=broad-exception-caught # TODO: narrow exception type
|
|
444
|
+
# Log exceptions
|
|
445
|
+
elapsed_ms = round((time.time() - start_time) * 1000, 2)
|
|
446
|
+
|
|
447
|
+
if has_structured:
|
|
448
|
+
structlogger.error(
|
|
449
|
+
"Function failed",
|
|
450
|
+
function=func_name,
|
|
451
|
+
execution_time_ms=elapsed_ms,
|
|
452
|
+
error_type=type(e).__name__,
|
|
453
|
+
error_message=str(e),
|
|
454
|
+
module=module_name,
|
|
455
|
+
lineno=line_no,
|
|
456
|
+
exc_info=True,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
func_logger.error(
|
|
460
|
+
"%s FAILED: %s: %s (%sms)",
|
|
461
|
+
func_name,
|
|
462
|
+
type(e).__name__,
|
|
463
|
+
str(e),
|
|
464
|
+
elapsed_ms,
|
|
465
|
+
exc_info=True,
|
|
466
|
+
)
|
|
467
|
+
raise
|
|
468
|
+
|
|
469
|
+
return cast(Callable[..., T], wrapper)
|
|
470
|
+
|
|
471
|
+
# Handle both @log_function_call and @log_function_call(sensitive_fields=[...])
|
|
472
|
+
if func is not None:
|
|
473
|
+
# Called without parentheses: @log_function_call
|
|
474
|
+
return decorator(func)
|
|
475
|
+
# Called with parentheses: @log_function_call(sensitive_fields=[...])
|
|
476
|
+
return decorator
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Redaction utilities for sanitising sensitive data before logging."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"redact_for_logging",
|
|
8
|
+
"redact_env_vars",
|
|
9
|
+
"SENSITIVE_KEY_PATTERNS",
|
|
10
|
+
"REDACTED_VALUE",
|
|
11
|
+
"RedactableDict",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
# Type alias for dictionaries that can have string or tuple keys
|
|
15
|
+
# Used by redact_for_logging to handle get_config_values() return format
|
|
16
|
+
RedactableDict = dict[str | tuple[str, ...], Any]
|
|
17
|
+
|
|
18
|
+
# Redaction placeholder for sensitive values
|
|
19
|
+
REDACTED_VALUE = "***"
|
|
20
|
+
|
|
21
|
+
# Substring patterns for identifying sensitive env var keys (case-insensitive)
|
|
22
|
+
SENSITIVE_KEY_PATTERNS: frozenset[str] = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"token",
|
|
25
|
+
"secret",
|
|
26
|
+
"password",
|
|
27
|
+
"credential",
|
|
28
|
+
"api_key",
|
|
29
|
+
"access_key",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def redact_for_logging(
|
|
35
|
+
data: RedactableDict,
|
|
36
|
+
sensitive_fields: set[str],
|
|
37
|
+
) -> RedactableDict:
|
|
38
|
+
"""Create a copy of data with sensitive fields redacted for logging.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
data: Dictionary containing data to be logged.
|
|
42
|
+
sensitive_fields: Set of field names whose values should be redacted.
|
|
43
|
+
For tuple keys, the last element of the tuple is checked against
|
|
44
|
+
sensitive_fields (e.g., ("github", "token") matches "token").
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A shallow copy of data with sensitive field values replaced by "***".
|
|
48
|
+
Nested dictionaries are processed recursively.
|
|
49
|
+
"""
|
|
50
|
+
result = data.copy()
|
|
51
|
+
for key in result:
|
|
52
|
+
# Check if key matches sensitive fields
|
|
53
|
+
# For tuple keys, check the last element
|
|
54
|
+
key_to_check: str | None = None
|
|
55
|
+
if isinstance(key, str):
|
|
56
|
+
key_to_check = key
|
|
57
|
+
elif isinstance(key, tuple) and len(key) > 0:
|
|
58
|
+
last_element = key[-1]
|
|
59
|
+
if isinstance(last_element, str):
|
|
60
|
+
key_to_check = last_element
|
|
61
|
+
|
|
62
|
+
if key_to_check is not None and key_to_check in sensitive_fields:
|
|
63
|
+
result[key] = REDACTED_VALUE
|
|
64
|
+
elif isinstance(result[key], dict):
|
|
65
|
+
result[key] = redact_for_logging(result[key], sensitive_fields)
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def redact_env_vars(
|
|
70
|
+
env: Mapping[str, str],
|
|
71
|
+
extra_patterns: frozenset[str] | None = None,
|
|
72
|
+
) -> dict[str, str]:
|
|
73
|
+
"""Redact env var values whose keys contain sensitive substrings (case-insensitive).
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
env: Mapping of environment variable names to values.
|
|
77
|
+
extra_patterns: Additional substring patterns to merge with defaults.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A new dict with sensitive values replaced by the redaction placeholder.
|
|
81
|
+
"""
|
|
82
|
+
patterns = (
|
|
83
|
+
SENSITIVE_KEY_PATTERNS | extra_patterns
|
|
84
|
+
if extra_patterns
|
|
85
|
+
else SENSITIVE_KEY_PATTERNS
|
|
86
|
+
)
|
|
87
|
+
result: dict[str, str] = {}
|
|
88
|
+
for key, value in env.items():
|
|
89
|
+
key_lower = key.lower()
|
|
90
|
+
if any(pattern in key_lower for pattern in patterns):
|
|
91
|
+
result[key] = REDACTED_VALUE
|
|
92
|
+
else:
|
|
93
|
+
result[key] = value
|
|
94
|
+
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-coder-utils
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Shared low-level Python helpers (subprocess, logging, fs) for the mcp-coder family of repos
|
|
5
5
|
Author-email: Marcus Jellinghaus <Marcus@Jellinghaus.ch>
|
|
6
6
|
Project-URL: Homepage, https://github.com/MarcusJellinghaus/mcp-coder-utils
|
|
@@ -46,7 +46,9 @@ vulture_whitelist.py
|
|
|
46
46
|
.github/workflows/publish.yml
|
|
47
47
|
docs/architecture/architecture.md
|
|
48
48
|
src/mcp_coder_utils/__init__.py
|
|
49
|
+
src/mcp_coder_utils/log_utils.py
|
|
49
50
|
src/mcp_coder_utils/py.typed
|
|
51
|
+
src/mcp_coder_utils/redaction.py
|
|
50
52
|
src/mcp_coder_utils/subprocess_runner.py
|
|
51
53
|
src/mcp_coder_utils/subprocess_streaming.py
|
|
52
54
|
src/mcp_coder_utils.egg-info/PKG-INFO
|
|
@@ -55,6 +57,8 @@ src/mcp_coder_utils.egg-info/dependency_links.txt
|
|
|
55
57
|
src/mcp_coder_utils.egg-info/requires.txt
|
|
56
58
|
src/mcp_coder_utils.egg-info/top_level.txt
|
|
57
59
|
tests/__init__.py
|
|
60
|
+
tests/test_log_utils.py
|
|
61
|
+
tests/test_redaction.py
|
|
58
62
|
tests/test_subprocess_runner.py
|
|
59
63
|
tests/test_subprocess_runner_real.py
|
|
60
64
|
tests/test_subprocess_streaming.py
|