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.
Files changed (70) hide show
  1. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/settings.local.json +27 -20
  2. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/PKG-INFO +1 -1
  3. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/docs/architecture/architecture.md +1 -0
  4. mcp_coder_utils-0.1.3/src/mcp_coder_utils/log_utils.py +476 -0
  5. mcp_coder_utils-0.1.3/src/mcp_coder_utils/redaction.py +94 -0
  6. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils.egg-info/PKG-INFO +1 -1
  7. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils.egg-info/SOURCES.txt +4 -0
  8. mcp_coder_utils-0.1.3/tests/test_log_utils.py +789 -0
  9. mcp_coder_utils-0.1.3/tests/test_redaction.py +225 -0
  10. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/vulture_whitelist.py +8 -0
  11. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/CLAUDE.md +0 -0
  12. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/agents/commit-pusher.md +0 -0
  13. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/knowledge_base/planning_principles.md +0 -0
  14. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/knowledge_base/python.md +0 -0
  15. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/knowledge_base/refactoring_principles.md +0 -0
  16. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/knowledge_base/software_engineering_principles.md +0 -0
  17. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/check_branch_status/SKILL.md +0 -0
  18. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/commit_push/SKILL.md +0 -0
  19. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/discuss/SKILL.md +0 -0
  20. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implement_direct/SKILL.md +0 -0
  21. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implementation_approve/SKILL.md +0 -0
  22. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implementation_finalise/SKILL.md +0 -0
  23. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implementation_needs_rework/SKILL.md +0 -0
  24. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implementation_new_tasks/SKILL.md +0 -0
  25. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implementation_review/SKILL.md +0 -0
  26. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/implementation_review_supervisor/SKILL.md +0 -0
  27. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/issue_analyse/SKILL.md +0 -0
  28. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/issue_approve/SKILL.md +0 -0
  29. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/issue_create/SKILL.md +0 -0
  30. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/issue_requirements/SKILL.md +0 -0
  31. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/issue_update/SKILL.md +0 -0
  32. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/plan_approve/SKILL.md +0 -0
  33. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/plan_review/SKILL.md +0 -0
  34. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/plan_review_supervisor/SKILL.md +0 -0
  35. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/plan_update/SKILL.md +0 -0
  36. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/rebase/SKILL.md +0 -0
  37. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.claude/skills/rebase/rebase_design.md +0 -0
  38. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.gitattributes +0 -0
  39. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.github/dependabot.yml +0 -0
  40. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.github/workflows/approve-command.yml +0 -0
  41. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.github/workflows/ci.yml +0 -0
  42. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.github/workflows/label-new-issues.yml +0 -0
  43. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.github/workflows/publish.yml +0 -0
  44. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.gitignore +0 -0
  45. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.importlinter +0 -0
  46. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.large-files-allowlist +0 -0
  47. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.mcp.json +0 -0
  48. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/.python-version +0 -0
  49. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/LICENSE +0 -0
  50. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/README.md +0 -0
  51. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/claude.bat +0 -0
  52. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/claude_local.bat +0 -0
  53. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/pyproject.toml +0 -0
  54. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/setup.cfg +0 -0
  55. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils/__init__.py +0 -0
  56. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils/py.typed +0 -0
  57. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils/subprocess_runner.py +0 -0
  58. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils/subprocess_streaming.py +0 -0
  59. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils.egg-info/dependency_links.txt +0 -0
  60. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils.egg-info/requires.txt +0 -0
  61. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/src/mcp_coder_utils.egg-info/top_level.txt +0 -0
  62. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tests/__init__.py +0 -0
  63. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tests/test_subprocess_runner.py +0 -0
  64. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tests/test_subprocess_runner_real.py +0 -0
  65. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tests/test_subprocess_streaming.py +0 -0
  66. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/todo_issues.md +0 -0
  67. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tools/read_github_deps.py +0 -0
  68. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tools/reinstall_local.bat +0 -0
  69. {mcp_coder_utils-0.1.2 → mcp_coder_utils-0.1.3}/tools/ruff_check.bat +0 -0
  70. {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(mcp-coder check file-size:*)",
34
- "Bash(mcp-coder check branch-status:*)",
35
- "Bash(mcp-coder gh-tool:*)",
36
- "Bash(mcp-coder git-tool:*)",
37
- "Bash(find:*)",
38
- "Bash(ruff rule:*)",
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.2
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
@@ -41,6 +41,7 @@ consumed by `mcp-coder`, `mcp-tools-py`, `mcp-workspace`, and `mcp-config`.
41
41
  src/mcp_coder_utils/
42
42
  __init__.py
43
43
  py.typed
44
+ log_utils.py
44
45
  subprocess_runner.py
45
46
  subprocess_streaming.py
46
47
  ```
@@ -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.2
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