ptl-tool: add --log option to also write output to a per-run log file

Adds a --log flag to ptl-tool.py. When set (and not combined with
--dry-run, which suppresses logging entirely), a per-run log file
captures the whole run's output in the current working directory.

The log filename uses this run's fully-resolved branch name (timestamp
and base/release suffixes already applied, computed in build_branch()),
falling back to the generic ptl-tool.log when no branch name is
available.

Records sent through the module logger land in the file via
add_file_log_handler(), which also returns the resolved path. Plain
print() output and interactive prompts/responses are captured through
an explicit logged_input() helper that all input() call sites now use,
rather than monkeypatching sys.stdout/builtins.input.

Addresses review feedback from batrick: log path resolves against
os.getcwd() instead of the git checkout directory, and prompt/response
logging goes through an explicit helper instead of stdout/input
monkeypatching.

Signed-off-by: Yuri Weinstein <yweinste@redhat.com>
This commit is contained in:
Yuri Weinstein 2026-07-30 07:25:31 -07:00
parent ff681c5884
commit a9e14a1fa2
2 changed files with 175 additions and 61 deletions

View File

@ -153,6 +153,35 @@ log = logging.getLogger(__name__)
log.addHandler(logging.StreamHandler())
log.setLevel(logging.INFO)
def add_file_log_handler(logger, label=None):
"""--log support: also write logger's output to <label>.log (or ptl-tool.log
when no label is given) in the current working directory, copying the
console handler's formatter."""
filename = f"{label}.log" if label else "ptl-tool.log"
filepath = os.path.join(os.getcwd(), filename)
fh = logging.FileHandler(filepath)
fh.setLevel(logger.level)
for handler in logger.handlers:
if handler.formatter:
fh.setFormatter(handler.formatter)
break
logger.addHandler(fh)
return os.path.abspath(filepath)
def logged_input(prompt=''):
response = input(prompt)
for handler in log.handlers:
if isinstance(handler, logging.FileHandler):
handler.acquire()
try:
handler.stream.write(f"{prompt}{response}\n")
handler.flush()
finally:
handler.release()
return response
# find containing git dir
try:
git_dir = git.Repo(GITDIR, search_parent_directories=True).working_tree_dir
@ -516,7 +545,7 @@ def get_credits(session, pr, pr_req):
credits.add("Reviewed-by: "+NEW_CONTRIBUTORS[user])
except KeyError as e:
try:
name = input("Need name for contributor \"%s\" (use ^D to skip); Reviewed-by: " % user)
name = logged_input("Need name for contributor \"%s\" (use ^D to skip); Reviewed-by: " % user)
name = name.strip()
if len(name) == 0:
continue
@ -673,13 +702,13 @@ def post_draft_review(session, pr, initial_text, base=None):
with open(tf_path, 'r', encoding='utf-8') as f_read:
final_text = f_read.read().strip()
print("\n" + "="*80)
print("DRAFT REVIEW PREVIEW:")
log.info("\n" + "="*80)
log.info("DRAFT REVIEW PREVIEW:")
print("-" * 80)
print(final_text)
print("="*80 + "\n")
log.info(final_text)
log.info("="*80 + "\n")
confirm = input(f"Post this feedback to PR #{pr}? [r/c/e/m/N] (r=request changes, c=comment, e=edit again, m=skip to merge, n=cancel): ").strip().lower()
confirm = logged_input(f"Post this feedback to PR #{pr}? [r/c/e/m/N] (r=request changes, c=comment, e=edit again, m=skip to merge, n=cancel): ").strip().lower()
if confirm == 'm':
raise SkipToMerge()
elif confirm in ('r', 'c'):
@ -695,7 +724,7 @@ def post_draft_review(session, pr, initial_text, base=None):
elif confirm == 'e':
continue
else:
print("Review cancelled.")
log.info("Review cancelled.")
return False
finally:
os.unlink(tf_path)
@ -793,11 +822,11 @@ class CommitParityCheck(BaseAuditCheck):
if found_prs:
log.info(f"Original PRs identified in this backport: {', '.join(sorted(list(found_prs)))}")
if len(found_prs) > 1:
print("\033[91m" + "="*80)
print("WARNING: Multiple original PRs detected in this backport!")
print("Normally we expect exactly one main PR per backport.")
print("Detected: " + ", ".join(sorted(list(found_prs))))
print("="*80 + "\033[0m")
log.info("\033[91m" + "="*80)
log.info("WARNING: Multiple original PRs detected in this backport!")
log.info("Normally we expect exactly one main PR per backport.")
log.info("Detected: " + ", ".join(sorted(list(found_prs))))
log.info("="*80 + "\033[0m")
return {
'found_prs': found_prs,
@ -901,11 +930,11 @@ class CommitParityCheck(BaseAuditCheck):
md_text += "\n[Be familiar with the rules and guidelines for writing backports.](https://github.com/ceph/ceph/blob/main/SubmittingPatches-backports.rst)\n\n"
while True:
ans = input("Do you want to allow these commits anyway? [p/m/r/o/q] (p=proceed, m=skip to merge, r=add to review, o=open PR in browser, q=quit) ").strip().lower()
ans = logged_input("Do you want to allow these commits anyway? [p/m/r/o/q] (p=proceed, m=skip to merge, r=add to review, o=open PR in browser, q=quit) ").strip().lower()
if ans == 'o':
url = f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/pull/{ctx.pr}"
open_in_browser([url])
print(f"Opened {url} in browser.")
log.info(f"Opened {url} in browser.")
elif ans == 'm':
raise SkipToMerge()
elif ans == 'r':
@ -917,7 +946,7 @@ class CommitParityCheck(BaseAuditCheck):
elif ans == 'p':
break
else:
print("Invalid choice. Please enter p, m, r, o, or q.")
log.info("Invalid choice. Please enter p, m, r, o, or q.")
def _handle_unmerged_commits(self, ctx, unmerged_cps, cp_regex):
md_text = "### Automated Backport Parity Review - Unmerged Commits Detected\n\n"
@ -933,7 +962,7 @@ class CommitParityCheck(BaseAuditCheck):
ctx.report.add("Unmerged Cherry-Picks", md_text)
else:
while True:
ans = input("Unmerged cherry-picks detected! [p]roceed, [m] skip to merge, [o]pen browser to investigate, [r]eview PR (add to review), [q]uit: ").strip().lower()
ans = logged_input("Unmerged cherry-picks detected! [p]roceed, [m] skip to merge, [o]pen browser to investigate, [r]eview PR (add to review), [q]uit: ").strip().lower()
if ans == 'p':
break
elif ans == 'm':
@ -953,9 +982,9 @@ class CommitParityCheck(BaseAuditCheck):
urls_to_open.append(f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/commit/{orig_sha}")
urls_to_open.append(f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/commit/{c.hexsha}")
open_in_browser(urls_to_open)
print("Opened URLs in browser.")
log.info("Opened URLs in browser.")
else:
print("Invalid choice. Please enter p, m, o, r, or q.")
log.info("Invalid choice. Please enter p, m, o, r, or q.")
def _handle_parity_mismatch(self, ctx, missing_commits, extra_commits, found_prs):
md_text = f"### Automated Backport Parity Review\n\n"
@ -968,7 +997,7 @@ class CommitParityCheck(BaseAuditCheck):
ctx.report.add("Parity Mismatch", md_text)
else:
while True:
ans = input("Parity mismatch! [p]roceed, [m] skip to merge, [o]pen browser to investigate, [r]eview PR (add to review), [q]uit: ").strip().lower()
ans = logged_input("Parity mismatch! [p]roceed, [m] skip to merge, [o]pen browser to investigate, [r]eview PR (add to review), [q]uit: ").strip().lower()
if ans == 'p':
break
elif ans == 'm':
@ -992,9 +1021,9 @@ class CommitParityCheck(BaseAuditCheck):
for c in extra_commits:
urls_to_open.append(f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/commit/{c.hexsha}")
open_in_browser(urls_to_open)
print("Opened URLs in browser.")
log.info("Opened URLs in browser.")
else:
print("Invalid choice. Please enter p, m, o, r, or q.")
log.info("Invalid choice. Please enter p, m, o, r, or q.")
def _handle_multiple_prs(self, ctx, found_prs):
md_text = f"### Automated Backport Parity Review - Multiple PRs Detected\n\n"
@ -1005,7 +1034,7 @@ class CommitParityCheck(BaseAuditCheck):
ctx.report.add("Multiple Source PRs", md_text)
else:
while True:
ans = input("Multiple original PRs detected! Do you want to add a review requesting justification? [p/r/m/o] (p=proceed/ignore, r=add to review, m=skip to merge, o=open PRs in browser): ").strip().lower()
ans = logged_input("Multiple original PRs detected! Do you want to add a review requesting justification? [p/r/m/o] (p=proceed/ignore, r=add to review, m=skip to merge, o=open PRs in browser): ").strip().lower()
if ans == 'o':
url = f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/pull/{ctx.pr}"
urls_to_open = [url]
@ -1014,7 +1043,7 @@ class CommitParityCheck(BaseAuditCheck):
if m_pr:
urls_to_open.append(f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/pull/{m_pr.group(1)}")
open_in_browser(urls_to_open)
print("Opened URLs in browser.")
log.info("Opened URLs in browser.")
elif ans == 'r':
ctx.report.add("Multiple Source PRs", md_text)
ctx.report.record_failure()
@ -1024,7 +1053,7 @@ class CommitParityCheck(BaseAuditCheck):
elif ans == 'p':
break
else:
print("Invalid choice. Please enter p, r, m, or o.")
log.info("Invalid choice. Please enter p, r, m, or o.")
def run(self, ctx: AuditContext) -> None:
log.info("Verifying commit parity with original PR(s) locally...")
@ -1036,7 +1065,7 @@ class CommitParityCheck(BaseAuditCheck):
vis_text, vis_md = self._generate_visualizer(ctx.pr_commits, ctx.pr, mapping['pr_mapping'], mapping['missing_commits'])
if vis_text:
print(vis_text)
log.info(vis_text)
if vis_md:
ctx.report.set_visualizer(vis_md)
@ -1083,13 +1112,13 @@ class CommitParityCheck(BaseAuditCheck):
if ctx.args.ci_mode:
ctx.report.add("Scrambled Commits", scramble_msg)
else:
print("\033[91m" + "="*80)
print("WARNING: Scrambled commits detected in the backport!")
log.info("\033[91m" + "="*80)
log.info("WARNING: Scrambled commits detected in the backport!")
for reason in scramble_reasons:
print(f" - {reason}")
print("="*80 + "\033[0m")
log.info(f" - {reason}")
log.info("="*80 + "\033[0m")
while True:
ans = input("Do you want to request changes for scrambled commits? [p/r/m/q] (p=proceed anyway, r=add to review, m=skip to merge, q=quit): ").strip().lower()
ans = logged_input("Do you want to request changes for scrambled commits? [p/r/m/q] (p=proceed anyway, r=add to review, m=skip to merge, q=quit): ").strip().lower()
if ans == 'm':
raise SkipToMerge()
elif ans == 'r':
@ -1101,7 +1130,7 @@ class CommitParityCheck(BaseAuditCheck):
elif ans == 'p':
break
else:
print("Invalid choice. Please enter p, r, m, or q.")
log.info("Invalid choice. Please enter p, r, m, or q.")
if invalid_format_commits:
self._handle_invalid_formats(ctx, invalid_format_commits)
@ -1115,7 +1144,7 @@ class CommitParityCheck(BaseAuditCheck):
if mapping['missing_commits'] or extra_commits:
self._handle_parity_mismatch(ctx, mapping['missing_commits'], extra_commits, mapping['found_prs'])
elif mapping['analyzed_merges']:
print("\033[92mCommit parity check passed! All upstream commits from identified PRs are present.\033[0m")
log.info("\033[92mCommit parity check passed! All upstream commits from identified PRs are present.\033[0m")
if len(mapping['found_prs']) > 1:
self._handle_multiple_prs(ctx, mapping['found_prs'])
@ -1192,11 +1221,11 @@ class ConflictSimulationCheck(BaseAuditCheck):
raise SkipToMerge()
while True:
ans = input("How do you want to handle this? [p/m/r/o/q] (p=proceed simulation anyway, m=skip to merge, r=add to review and skip simulation, o=open PR in browser, q=quit) ").strip().lower()
ans = logged_input("How do you want to handle this? [p/m/r/o/q] (p=proceed simulation anyway, m=skip to merge, r=add to review and skip simulation, o=open PR in browser, q=quit) ").strip().lower()
if ans == 'o':
url = f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/pull/{pr}"
open_in_browser([url])
print(f"Opened {url} in browser.")
log.info(f"Opened {url} in browser.")
elif ans == 'm':
raise SkipToMerge()
elif ans == 'r':
@ -1209,7 +1238,7 @@ class ConflictSimulationCheck(BaseAuditCheck):
elif ans == 'p':
break
else:
print("Invalid choice. Please enter p, m, r, o, or q.")
log.info("Invalid choice. Please enter p, m, r, o, or q.")
backport_tree = wt_repo.head.commit.tree.hexsha
@ -1225,16 +1254,16 @@ class ConflictSimulationCheck(BaseAuditCheck):
ans = 'i'
else:
while True:
ans = input(f"Conflict or unapproved deviation detected in {c.hexsha[:8]}. Do you want to interactively review this PR? [i/a/m/o]\n(i = interactively review, a = auto-approve remaining, m = skip to merge, o = open in browser) ").strip().lower()
ans = logged_input(f"Conflict or unapproved deviation detected in {c.hexsha[:8]}. Do you want to interactively review this PR? [i/a/m/o]\n(i = interactively review, a = auto-approve remaining, m = skip to merge, o = open in browser) ").strip().lower()
if ans == 'o':
bp_pr_url = f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/pull/{pr}"
commit_url = f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/commit/{c.hexsha}"
open_in_browser([bp_pr_url, commit_url])
print("Opened PR and commit URLs in browser.")
log.info("Opened PR and commit URLs in browser.")
elif ans in ['i', 'a', 'm']:
break
else:
print("Invalid choice. Please enter i, a, m, or o.")
log.info("Invalid choice. Please enter i, a, m, or o.")
first_conflict = False
if ans == 'm':
log.info("Skipping ahead to merge.")
@ -1327,20 +1356,20 @@ class ConflictSimulationCheck(BaseAuditCheck):
ans = 'r'
break
ans = input(prompt_text).strip().lower()
ans = logged_input(prompt_text).strip().lower()
if ans == 'o':
bp_pr_url = f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/pull/{pr}"
orig_commit_url = f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/commit/{c.hexsha}"
bp_commit_url = f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/commit/{commit.hexsha}"
open_in_browser([bp_pr_url, orig_commit_url, bp_commit_url])
print("Opened relevant URLs in browser.")
log.info("Opened relevant URLs in browser.")
elif ans == 'e':
log.info(f"Re-opening {editor} to examine conflicts...")
open_editor = True
elif ans in ['p', 'r', 's', 'm']:
break
else:
print("Invalid choice. Please enter p, r, s, m, o, or e.")
log.info("Invalid choice. Please enter p, r, s, m, o, or e.")
if ans == 'm':
log.info("Skipping ahead to merge.")
raise SkipToMerge()
@ -1375,11 +1404,11 @@ class ConflictSimulationCheck(BaseAuditCheck):
raise SkipToMerge()
while True:
ans = input("How do you want to handle this? [p/m/r/o/q] (p=proceed simulation anyway, m=skip to merge, r=add to review and skip simulation, o=open PR in browser, q=quit) ").strip().lower()
ans = logged_input("How do you want to handle this? [p/m/r/o/q] (p=proceed simulation anyway, m=skip to merge, r=add to review and skip simulation, o=open PR in browser, q=quit) ").strip().lower()
if ans == 'o':
url = f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/pull/{pr}"
open_in_browser([url])
print(f"Opened {url} in browser.")
log.info(f"Opened {url} in browser.")
elif ans == 'm':
raise SkipToMerge()
elif ans == 'r':
@ -1392,7 +1421,7 @@ class ConflictSimulationCheck(BaseAuditCheck):
elif ans == 'p':
break
else:
print("Invalid choice. Please enter p, m, r, o, or q.")
log.info("Invalid choice. Please enter p, m, r, o, or q.")
if recorded_deviations:
md_text = """
@ -1514,11 +1543,11 @@ class RedmineLinkageCheck(BaseAuditCheck):
if match and int(match.group(1)) == orig_pr:
main_trackers.append(issue)
log.info(f"Found main tracker #{issue.id} ({REDMINE_ENDPOINT}/issues/{issue.id}) via description search.")
print(f"Found PR #{orig_pr} in description of issue #{issue.id}.")
log.info(f"Found PR #{orig_pr} in description of issue #{issue.id}.")
if args.ci_mode:
irregularities.append(f"**Malformed Main Tracker:** Main tracker [#{issue.id}]({REDMINE_ENDPOINT}/issues/{issue.id}) has the PR link in the description rather than the 'Pull Request ID' field. Please fix this.")
else:
ans = input(f"Fix tracker #{issue.id} by moving PR link from description to 'Pull Request ID' field? [y/N/m]: ").strip().lower()
ans = logged_input(f"Fix tracker #{issue.id} by moving PR link from description to 'Pull Request ID' field? [y/N/m]: ").strip().lower()
if ans == 'm':
raise SkipToMerge()
elif ans == 'y':
@ -1569,11 +1598,11 @@ class RedmineLinkageCheck(BaseAuditCheck):
if match:
found_pr = match.group(1)
log.debug(f"Found PR #{found_pr} in description of backport tracker #{bp_tracker.id}")
print(f"Found PR #{found_pr} in description of backport tracker #{bp_tracker.id}.")
log.info(f"Found PR #{found_pr} in description of backport tracker #{bp_tracker.id}.")
if args.ci_mode:
irregularities.append(f"**Malformed Backport Tracker:** Backport tracker [#{bp_tracker.id}]({REDMINE_ENDPOINT}/issues/{bp_tracker.id}) has the PR link in the description rather than the 'Pull Request ID' field. Please fix this.")
else:
ans = input(f"Fix tracker #{bp_tracker.id} by moving PR link from description to 'Pull Request ID' field? [y/N/m]: ").strip().lower()
ans = logged_input(f"Fix tracker #{bp_tracker.id} by moving PR link from description to 'Pull Request ID' field? [y/N/m]: ").strip().lower()
if ans == 'm':
raise SkipToMerge()
elif ans == 'y':
@ -1610,15 +1639,15 @@ class RedmineLinkageCheck(BaseAuditCheck):
if notes:
md_text += "\n" + "\n".join([f"* {note}" for note in notes]) + "\n"
print("\n\033[93m" + "="*80)
print("REDMINE LINKAGE IRREGULARITIES DETECTED")
print("="*80 + "\033[0m")
log.info("\n\033[93m" + "="*80)
log.info("REDMINE LINKAGE IRREGULARITIES DETECTED")
log.info("="*80 + "\033[0m")
if args.ci_mode:
report.add("Redmine Linkage", md_text)
else:
while True:
ans = input("Redmine irregularities detected! [p/r/m] (p=proceed, r=add to review, m=skip to merge): ").strip().lower()
ans = logged_input("Redmine irregularities detected! [p/r/m] (p=proceed, r=add to review, m=skip to merge): ").strip().lower()
if ans == 'p':
break
elif ans == 'm':
@ -1662,11 +1691,11 @@ class MergeConflictCheck(BaseAuditCheck):
report.add("Base Conflicts", md_text)
else:
while True:
ans = input(f"PR #{pr} needs a rebase! Do you want to add a review requesting a rebase? [p/r/m/q/o] (p=proceed/skip check, r=add to review, m=skip to merge, q=abort, o=open PR in browser): ").strip().lower()
ans = logged_input(f"PR #{pr} needs a rebase! Do you want to add a review requesting a rebase? [p/r/m/q/o] (p=proceed/skip check, r=add to review, m=skip to merge, q=abort, o=open PR in browser): ").strip().lower()
if ans == 'o':
url = f"https://github.com/{BASE_PROJECT}/{BASE_REPO}/pull/{pr}"
open_in_browser([url])
print(f"Opened {url} in browser.")
log.info(f"Opened {url} in browser.")
elif ans == 'm':
raise SkipToMerge()
elif ans == 'p':
@ -1680,7 +1709,7 @@ class MergeConflictCheck(BaseAuditCheck):
log.error(f"Aborting script due to unmergeable PR #{pr}.")
sys.exit(1)
else:
print("Invalid choice. Please enter p, r, m, q, or o.")
log.info("Invalid choice. Please enter p, r, m, q, or o.")
def write_ci_summary(pr: int, passed: bool, exit_code: int = 1):
if not passed:
@ -2063,10 +2092,10 @@ def build_branch(args):
elif len(matching_tickets) == 1:
t = matching_tickets[0]
t_url = f"{REDMINE_ENDPOINT}/issues/{t.id}"
print(f"\nFound existing open QA ticket: #{t.id} - {t.subject}")
print(f"Link: {t_url}")
log.info(f"\nFound existing open QA ticket: #{t.id} - {t.subject}")
log.info(f"Link: {t_url}")
while True:
ans = input("Do you want to update this existing QA ticket? [y/n/o/q] (y=update existing, n=create new, o=open in browser, q=quit): ").strip().lower()
ans = logged_input("Do you want to update this existing QA ticket? [y/n/o/q] (y=update existing, n=create new, o=open in browser, q=quit): ").strip().lower()
if ans == 'y':
args.update_qa = t.id
log.info(f"Will update existing QA ticket #{t.id}.")
@ -2077,14 +2106,14 @@ def build_branch(args):
break
elif ans == 'o':
open_in_browser([t_url])
print(f"Opened {t_url} in browser.")
log.info(f"Opened {t_url} in browser.")
elif ans == 'q':
log.info("Exiting script.")
sys.exit(0)
elif ans == '':
continue
else:
print("Invalid choice. Please enter y, n, o, or q.")
log.info("Invalid choice. Please enter y, n, o, or q.")
else:
log.info("No open QA tickets found for this label. Will create a new QA ticket.")
args.create_qa = True
@ -2113,6 +2142,9 @@ def build_branch(args):
if args.integration or merge_branch_name is False:
merge_branch_name = branch
if args.log and not args.dry_run:
add_file_log_handler(log, label=branch)
if base == 'HEAD':
log.info("Branch base is HEAD; not checking out!")
else:
@ -2160,7 +2192,7 @@ def build_branch(args):
if not audit_passed and args.final_merge:
log.error(f"Audit of PR #{pr} failed.")
ans = input("Do you want to proceed with the final merge anyway? [y/N] ").strip().lower()
ans = logged_input("Do you want to proceed with the final merge anyway? [y/N] ").strip().lower()
if ans != 'y':
log.error("Aborting final merge.")
sys.exit(1)
@ -2370,6 +2402,7 @@ def main():
group = parser.add_argument_group('General Options')
group.add_argument('--debug', dest='debug', action='store_true', help='turn debugging on')
group.add_argument('--log', dest='log', action='store_true', help='also log to <branch>.log (or ptl-tool.log if no branch) in the current working directory')
group.add_argument('--dry-run', dest='dry_run', action='store_true', help='print actions without modifying remote state')
group.add_argument('--examples', dest='examples', action='store_true', help='show extended examples and usage')
group.add_argument('--git-dir', dest='git', action='store', default=git_dir, help='git directory')

View File

@ -11,6 +11,7 @@ Run with:
/tmp/ptl-test-venv/bin/pip install GitPython python-redmine requests pytest
/tmp/ptl-test-venv/bin/pytest src/script/test_ptl_tool.py -v
"""
import builtins
import importlib.util
import logging
import sys
@ -136,3 +137,83 @@ def test_post_consolidated_review_noop_when_no_issues(ptl_tool):
session = mock.Mock()
report.post_consolidated_review(session, pr=12345, dry_run=False)
session.post.assert_not_called()
def test_log_flag_adds_filehandler(ptl_tool, tmp_path, monkeypatch):
"""Without a label, the log file should be the generic ptl-tool.log in cwd."""
logger = ptl_tool.log
handlers_before = list(logger.handlers)
monkeypatch.chdir(tmp_path)
try:
ret = ptl_tool.add_file_log_handler(logger)
file_handlers = [
h for h in logger.handlers
if isinstance(h, logging.FileHandler)
]
assert len(file_handlers) == 1
expected = str(tmp_path / 'ptl-tool.log')
assert file_handlers[0].baseFilename == expected
assert ret == expected
finally:
logger.handlers = handlers_before
def test_log_flag_uses_label_for_filename(ptl_tool, tmp_path, monkeypatch):
"""When a label is provided, the log file should be <label>.log in cwd."""
logger = ptl_tool.log
handlers_before = list(logger.handlers)
monkeypatch.chdir(tmp_path)
try:
ptl_tool.add_file_log_handler(logger, label='wip-bharath8-testing')
file_handlers = [
h for h in logger.handlers
if isinstance(h, logging.FileHandler)
]
assert len(file_handlers) == 1
assert file_handlers[0].baseFilename == str(
tmp_path / 'wip-bharath8-testing.log')
finally:
logger.handlers = handlers_before
def test_prompt_logged_exactly_once(ptl_tool, tmp_path, monkeypatch):
"""logged_input() must record the prompt exactly once in the log file."""
logger = ptl_tool.log
handlers_before = list(logger.handlers)
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(builtins, "input", lambda prompt='': 'the-answer')
try:
log_path = ptl_tool.add_file_log_handler(logger, label="prompt-once")
result = ptl_tool.logged_input("ask> ")
assert result == "the-answer"
finally:
logger.handlers = handlers_before
contents = Path(log_path).read_text()
assert "ask> the-answer" in contents
assert contents.count("ask> ") == 1
def test_logged_input_records_prompt_and_response_with_filehandler(ptl_tool, tmp_path, monkeypatch):
"""logged_input() should capture both prompt text and typed response."""
logger = ptl_tool.log
handlers_before = list(logger.handlers)
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(builtins, "input", lambda prompt='': 'user-answer')
try:
log_path = ptl_tool.add_file_log_handler(logger, label="capture")
result = ptl_tool.logged_input("prompt> ")
assert result == "user-answer"
finally:
logger.handlers = handlers_before
contents = Path(log_path).read_text()
assert "prompt> user-answer" in contents
def test_logged_input_without_filehandler_preserves_behavior(ptl_tool, monkeypatch):
"""Without a FileHandler, logged_input() should behave like plain input()."""
monkeypatch.setattr(builtins, "input", lambda prompt='': 'plain-answer')
assert ptl_tool.logged_input("plain> ") == "plain-answer"