learnlog 0.1__py3-none-any.whl

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.
learnlog/__init__.py ADDED
@@ -0,0 +1,100 @@
1
+ """Automatic logging of student code development.
2
+
3
+ Import this module to record every program run: source code changes,
4
+ command-line arguments, stdin/stdout/stderr, and errors. Data is
5
+ stored in a hidden ``.learnlog/`` Git repository.
6
+ """
7
+
8
+ import sys
9
+ import os
10
+ import datetime
11
+ import atexit
12
+ import traceback
13
+
14
+ from learnlog.capture import IOLog, StreamCapture, InputCapture
15
+ from learnlog.gitrepo import LearnlogRepo
16
+
17
+ repo = None
18
+ iolog = None
19
+ original_stdout = None
20
+ original_stderr = None
21
+ original_stdin = None
22
+ exception_info = None
23
+ start_time = None
24
+ main_script = None
25
+ main_args = None
26
+
27
+
28
+ def initialize():
29
+ """Set up learnlog: detect script, init repo, wrap streams.
30
+
31
+ Called automatically on import. Safe to call multiple times;
32
+ subsequent calls are no-ops.
33
+ """
34
+ global repo, iolog, start_time, main_script, main_args
35
+ global original_stdout, original_stderr, original_stdin
36
+
37
+ if repo is not None:
38
+ return
39
+
40
+ try:
41
+ main_script = os.path.basename(sys.argv[0])
42
+ main_args = sys.argv[1:]
43
+ start_time = datetime.datetime.now()
44
+ workdir = os.getcwd()
45
+ repo = LearnlogRepo(workdir)
46
+ repo.begin_run(main_script, main_args, start_time)
47
+ iolog = IOLog()
48
+ original_stdout = sys.stdout
49
+ original_stderr = sys.stderr
50
+ original_stdin = sys.stdin
51
+ sys.stdout = StreamCapture(original_stdout, "stdout", iolog)
52
+ sys.stderr = StreamCapture(original_stderr, "stderr", iolog)
53
+ sys.stdin = InputCapture(original_stdin, iolog)
54
+ original_excepthook = sys.excepthook
55
+
56
+ def exception_hook(exc_type, exc_value, exc_tb):
57
+ global exception_info
58
+ exception_info = "".join(
59
+ traceback.format_exception(exc_type, exc_value, exc_tb)
60
+ )
61
+ original_excepthook(exc_type, exc_value, exc_tb)
62
+
63
+ sys.excepthook = exception_hook
64
+ atexit.register(on_exit)
65
+ except Exception:
66
+ pass
67
+
68
+
69
+ def on_exit():
70
+ """Finalise the learnlog entry on program exit.
71
+
72
+ Restores original streams and commits the I/O log, exception
73
+ information, and exit status.
74
+ """
75
+ global repo, iolog, exception_info
76
+ global original_stdout, original_stderr, original_stdin
77
+
78
+ if repo is None:
79
+ return
80
+
81
+ try:
82
+ if original_stdout is not None:
83
+ sys.stdout = original_stdout
84
+ if original_stderr is not None:
85
+ sys.stderr = original_stderr
86
+ if original_stdin is not None:
87
+ sys.stdin = original_stdin
88
+ end_time = datetime.datetime.now()
89
+ io_text = iolog.getvalue() if iolog else ""
90
+ repo.finalize_run(
91
+ io_log=io_text,
92
+ exception_info=exception_info,
93
+ end_time=end_time,
94
+ exit_code=0 if exception_info is None else 1,
95
+ )
96
+ except Exception:
97
+ pass
98
+
99
+
100
+ initialize()
learnlog/capture.py ADDED
@@ -0,0 +1,127 @@
1
+ """Stream capture wrappers for stdout, stderr, and stdin.
2
+
3
+ Provides transparent tee-like wrappers that record all I/O to a shared
4
+ buffer while passing data through to the original streams unchanged.
5
+ """
6
+
7
+ import io
8
+ import threading
9
+
10
+ MAX_IOLOG_BYTES = 1_000_000
11
+
12
+
13
+ class IOLog:
14
+ """Thread-safe shared buffer for interleaved I/O logging.
15
+
16
+ Each line is prefixed with the stream name (stdout, stderr, stdin)
17
+ to show the origin. The buffer is capped at ``max_bytes`` to
18
+ prevent unbounded memory growth.
19
+ """
20
+
21
+ def __init__(self, max_bytes=MAX_IOLOG_BYTES):
22
+ self.max_bytes = max_bytes
23
+ self.lock = threading.Lock()
24
+ self.buf = io.StringIO()
25
+ self.size = 0
26
+ self.truncated = False
27
+
28
+ def log(self, prefix, text):
29
+ """Log ``text`` with ``prefix`` (e.g. ``'stdout'``).
30
+
31
+ Each line of ``text`` gets its own prefixed entry.
32
+ If the buffer is full, further writes are silently dropped.
33
+ """
34
+ if not text:
35
+ return
36
+ with self.lock:
37
+ if self.truncated:
38
+ return
39
+ for line in text.splitlines(keepends=True):
40
+ entry = f"{prefix}: {line}"
41
+ if self.size + len(entry) > self.max_bytes:
42
+ self.buf.write(
43
+ "\n[learnlog: I/O log truncated "
44
+ f"at {self.max_bytes} bytes]\n"
45
+ )
46
+ self.truncated = True
47
+ return
48
+ self.buf.write(entry)
49
+ self.size += len(entry)
50
+
51
+ def getvalue(self):
52
+ """Return the full log contents as a string."""
53
+ with self.lock:
54
+ return self.buf.getvalue()
55
+
56
+
57
+ class StreamCapture:
58
+ """Transparent tee wrapper for an output stream.
59
+
60
+ Passes all writes through to the original stream while logging
61
+ them to a shared :class:`IOLog` with the given prefix.
62
+ """
63
+
64
+ def __init__(self, original, name, iolog):
65
+ self._original = original
66
+ self._name = name
67
+ self._iolog = iolog
68
+
69
+ def write(self, text):
70
+ """Write ``text`` to the original stream and log it."""
71
+ self._iolog.log(self._name, text)
72
+ return self._original.write(text)
73
+
74
+ def writelines(self, lines):
75
+ """Write an iterable of strings."""
76
+ for line in lines:
77
+ self.write(line)
78
+
79
+ def flush(self):
80
+ """Flush the original stream."""
81
+ return self._original.flush()
82
+
83
+ def __getattr__(self, name):
84
+ return getattr(self._original, name)
85
+
86
+
87
+ class InputCapture:
88
+ """Transparent tee wrapper for an input stream.
89
+
90
+ Passes all reads through from the original stream while logging
91
+ the data read to a shared :class:`IOLog`.
92
+ """
93
+
94
+ def __init__(self, original, iolog):
95
+ self._original = original
96
+ self._iolog = iolog
97
+
98
+ def readline(self, limit=-1):
99
+ """Read a line from the original stream and log it."""
100
+ line = self._original.readline(limit)
101
+ self._iolog.log("stdin", line)
102
+ return line
103
+
104
+ def read(self, size=-1):
105
+ """Read from the original stream and log it."""
106
+ data = self._original.read(size)
107
+ self._iolog.log("stdin", data)
108
+ return data
109
+
110
+ def readlines(self, hint=-1):
111
+ """Read lines from the original stream and log them."""
112
+ lines = self._original.readlines(hint)
113
+ for line in lines:
114
+ self._iolog.log("stdin", line)
115
+ return lines
116
+
117
+ def __iter__(self):
118
+ return self
119
+
120
+ def __next__(self):
121
+ line = self.readline()
122
+ if not line:
123
+ raise StopIteration
124
+ return line
125
+
126
+ def __getattr__(self, name):
127
+ return getattr(self._original, name)
learnlog/cli.py ADDED
@@ -0,0 +1,368 @@
1
+ """Command-line interface for learnlog.
2
+
3
+ Provides the ``learnlog`` command with subcommands for exporting
4
+ and playing back the development log stored in ``.learnlog/``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import pathlib
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from typing import Annotated, Optional
15
+
16
+ import typer
17
+
18
+ from learnlog.gitrepo import LearnlogRepo
19
+
20
+ DEFAULT_BUNDLE_NAME = "learnlog.bundle"
21
+
22
+
23
+ def find_learnlog_dir(start=None):
24
+ """Walk up from ``start`` to find a ``.learnlog/`` directory.
25
+
26
+ Returns the directory containing ``.learnlog/``, or ``None``
27
+ if no learnlog repository is found.
28
+ """
29
+ path = pathlib.Path(start or os.getcwd()).resolve()
30
+ while True:
31
+ if (path / ".learnlog").is_dir():
32
+ return path
33
+ parent = path.parent
34
+ if parent == path:
35
+ return None
36
+ path = parent
37
+
38
+
39
+ def resolve_source(source=None):
40
+ """Resolve a source into a ``(LearnlogRepo, tmpdir)`` pair.
41
+
42
+ ``source`` can be ``None`` (search current directory),
43
+ a directory path, or a bundle file. When a bundle is used,
44
+ ``tmpdir`` is the temporary directory that should be cleaned
45
+ up by the caller; otherwise it is ``None``.
46
+ """
47
+ if source is None:
48
+ workdir = find_learnlog_dir()
49
+ if workdir is None:
50
+ raise SystemExit(
51
+ "No .learnlog/ directory found. "
52
+ "Run this from a learnlog-tracked project."
53
+ )
54
+ return LearnlogRepo(workdir), None
55
+
56
+ path = pathlib.Path(source).resolve()
57
+
58
+ if path.is_dir():
59
+ if not (path / ".learnlog").is_dir():
60
+ raise SystemExit(f"No .learnlog/ directory in {path}")
61
+ return LearnlogRepo(path), None
62
+
63
+ if not path.is_file():
64
+ raise SystemExit(f"Not a file or directory: {path}")
65
+
66
+ verify = subprocess.run(
67
+ ["git", "bundle", "verify", str(path)],
68
+ capture_output=True,
69
+ text=True,
70
+ )
71
+ if verify.returncode != 0:
72
+ raise SystemExit(f"Not a valid bundle file: {path}")
73
+
74
+ tmpdir = tempfile.mkdtemp(prefix="learnlog-play-")
75
+ tmppath = pathlib.Path(tmpdir)
76
+
77
+ subprocess.run(
78
+ ["git", "clone", str(path), str(tmppath / "repo")],
79
+ capture_output=True,
80
+ text=True,
81
+ check=True,
82
+ )
83
+
84
+ cloned = tmppath / "repo"
85
+ (cloned / ".git").rename(cloned / ".learnlog")
86
+
87
+ return LearnlogRepo(cloned), tmpdir
88
+
89
+
90
+ def get_commits(repo):
91
+ """Return a list of commits in chronological order.
92
+
93
+ Each entry is a dict with ``hash``, ``subject``, and
94
+ ``date`` keys.
95
+ """
96
+ result = repo.git("log", "--format=%H\t%s\t%aI", "--reverse")
97
+ if result.returncode != 0:
98
+ return []
99
+ commits = []
100
+ for line in result.stdout.strip().split("\n"):
101
+ if not line:
102
+ continue
103
+ parts = line.split("\t", 2)
104
+ if len(parts) == 3:
105
+ commits.append(
106
+ {
107
+ "hash": parts[0],
108
+ "subject": parts[1],
109
+ "date": parts[2],
110
+ }
111
+ )
112
+ return commits
113
+
114
+
115
+ def get_commit_detail(repo, commit_hash):
116
+ """Return detailed information for a single commit.
117
+
118
+ Returns a dict with ``message`` (full commit message) and
119
+ ``diff`` (unified diff against parent).
120
+ """
121
+ msg_result = repo.git("log", "-1", "--format=%B", commit_hash)
122
+ message = msg_result.stdout.rstrip("\n")
123
+
124
+ parent_result = repo.git("rev-parse", commit_hash + "^")
125
+ if parent_result.returncode == 0:
126
+ diff_result = repo.git("diff", parent_result.stdout.strip(), commit_hash)
127
+ else:
128
+ diff_result = repo.git("diff", "--root", commit_hash)
129
+ diff = diff_result.stdout
130
+
131
+ return {"message": message, "diff": diff}
132
+
133
+
134
+ def do_export(repo, output):
135
+ """Export the learnlog repository as a Git bundle.
136
+
137
+ Creates a bundle file at ``output`` containing the full
138
+ commit history.
139
+ """
140
+ result = repo.git("bundle", "create", str(output), "--all")
141
+ if result.returncode != 0:
142
+ raise SystemExit(f"Failed to create bundle: {result.stderr}")
143
+
144
+
145
+ class PlaybackViewer:
146
+ """Interactive curses-based commit viewer.
147
+
148
+ Displays commits one at a time with vi-style navigation:
149
+ ``H``/``L`` for previous/next commit, ``j``/``k`` for
150
+ scrolling within a commit, ``q`` to quit.
151
+ """
152
+
153
+ def __init__(self, repo, commits, start_index=0):
154
+ self.repo = repo
155
+ self.commits = commits
156
+ self.index = start_index
157
+ self.scroll = 0
158
+ self._detail_cache = {}
159
+
160
+ def run(self):
161
+ """Enter curses mode and run the interactive viewer."""
162
+ import curses
163
+
164
+ curses.wrapper(self._main_loop)
165
+
166
+ def _main_loop(self, stdscr):
167
+ """Main event loop inside curses."""
168
+ import curses
169
+
170
+ curses.curs_set(0)
171
+ stdscr.clear()
172
+
173
+ while True:
174
+ self._draw(stdscr)
175
+ key = stdscr.getch()
176
+ if not self._handle_key(key):
177
+ break
178
+
179
+ def _get_detail(self, commit_hash):
180
+ """Fetch and cache commit detail."""
181
+ if commit_hash not in self._detail_cache:
182
+ self._detail_cache[commit_hash] = get_commit_detail(self.repo, commit_hash)
183
+ return self._detail_cache[commit_hash]
184
+
185
+ def _draw(self, stdscr):
186
+ """Render the current commit to the screen."""
187
+ stdscr.erase()
188
+ height, width = stdscr.getmaxyx()
189
+
190
+ commit = self.commits[self.index]
191
+ detail = self._get_detail(commit["hash"])
192
+
193
+ header = (
194
+ f"[{self.index + 1}/{len(self.commits)}] "
195
+ f"{commit['hash'][:7]} "
196
+ f"— {commit['subject']}"
197
+ )
198
+ header = header[: width - 1]
199
+ try:
200
+ import curses
201
+
202
+ stdscr.addstr(0, 0, header, curses.A_REVERSE)
203
+ stdscr.addstr(
204
+ 0,
205
+ len(header),
206
+ " " * (width - len(header) - 1),
207
+ curses.A_REVERSE,
208
+ )
209
+ except curses.error:
210
+ pass
211
+ content_text = detail["message"]
212
+ if detail["diff"]:
213
+ content_text += "\n\n" + detail["diff"]
214
+ content_lines = content_text.split("\n")
215
+ content_start = 1
216
+ content_end = height - 2
217
+ visible = content_end - content_start
218
+
219
+ self.scroll = max(
220
+ 0,
221
+ min(self.scroll, len(content_lines) - visible),
222
+ )
223
+
224
+ for i in range(visible):
225
+ line_idx = self.scroll + i
226
+ if line_idx >= len(content_lines):
227
+ break
228
+ line = content_lines[line_idx][: width - 1]
229
+ try:
230
+ stdscr.addstr(content_start + i, 0, line)
231
+ except curses.error:
232
+ pass
233
+ footer = (
234
+ " H/\u2190: prev L/\u2192: next "
235
+ "j/\u2193: down k/\u2191: up "
236
+ "g: first G: last q: quit"
237
+ )
238
+ footer = footer[: width - 1]
239
+ try:
240
+ import curses
241
+
242
+ stdscr.addstr(height - 1, 0, footer, curses.A_REVERSE)
243
+ remaining = width - len(footer) - 1
244
+ if remaining > 0:
245
+ stdscr.addstr(
246
+ height - 1,
247
+ len(footer),
248
+ " " * remaining,
249
+ curses.A_REVERSE,
250
+ )
251
+ except curses.error:
252
+ pass
253
+
254
+ stdscr.refresh()
255
+
256
+ def _handle_key(self, key):
257
+ """Process a keypress. Return ``False`` to quit."""
258
+ import curses
259
+
260
+ if key in (ord("q"), ord("Q")):
261
+ return False
262
+ elif key in (ord("h"), ord("H"), curses.KEY_LEFT):
263
+ if self.index > 0:
264
+ self.index -= 1
265
+ self.scroll = 0
266
+ elif key in (ord("l"), ord("L"), curses.KEY_RIGHT):
267
+ if self.index < len(self.commits) - 1:
268
+ self.index += 1
269
+ self.scroll = 0
270
+ elif key in (ord("j"), curses.KEY_DOWN):
271
+ self.scroll += 1
272
+ elif key in (ord("k"), curses.KEY_UP):
273
+ self.scroll = max(0, self.scroll - 1)
274
+ elif key == ord("g"):
275
+ self.index = 0
276
+ self.scroll = 0
277
+ elif key == ord("G"):
278
+ self.index = len(self.commits) - 1
279
+ self.scroll = 0
280
+
281
+ return True
282
+
283
+
284
+ app = typer.Typer(
285
+ name="learnlog",
286
+ help="Review student development logs.",
287
+ no_args_is_help=True,
288
+ )
289
+
290
+
291
+ @app.command()
292
+ def export(
293
+ output: Annotated[
294
+ pathlib.Path,
295
+ typer.Option(
296
+ "--output",
297
+ "-o",
298
+ help="Output bundle file path.",
299
+ ),
300
+ ] = pathlib.Path(DEFAULT_BUNDLE_NAME),
301
+ directory: Annotated[
302
+ Optional[pathlib.Path],
303
+ typer.Option(
304
+ "--dir",
305
+ "-C",
306
+ help="Directory containing .learnlog/.",
307
+ ),
308
+ ] = None,
309
+ ):
310
+ """Export the learnlog repository as a Git bundle."""
311
+ workdir = directory
312
+ if workdir is None:
313
+ workdir = find_learnlog_dir()
314
+ if workdir is None:
315
+ raise SystemExit("No .learnlog/ directory found.")
316
+ else:
317
+ workdir = pathlib.Path(workdir).resolve()
318
+ if not (workdir / ".learnlog").is_dir():
319
+ raise SystemExit(f"No .learnlog/ directory in {workdir}")
320
+
321
+ repo = LearnlogRepo(workdir)
322
+ do_export(repo, output.resolve())
323
+ typer.echo(f"Exported to {output}")
324
+
325
+
326
+ @app.command()
327
+ def play(
328
+ source: Annotated[
329
+ Optional[str],
330
+ typer.Argument(
331
+ help=(
332
+ "Source: directory with .learnlog/ "
333
+ "or a .bundle file. "
334
+ "Defaults to current directory."
335
+ ),
336
+ ),
337
+ ] = None,
338
+ start: Annotated[
339
+ Optional[str],
340
+ typer.Option(
341
+ "--start",
342
+ help="Commit hash to start playback from.",
343
+ ),
344
+ ] = None,
345
+ ):
346
+ """Interactively play back the development log."""
347
+ import shutil
348
+
349
+ repo, tmpdir = resolve_source(source)
350
+ try:
351
+ commits = get_commits(repo)
352
+ if not commits:
353
+ raise SystemExit("No commits found.")
354
+
355
+ start_index = 0
356
+ if start:
357
+ for i, c in enumerate(commits):
358
+ if c["hash"].startswith(start):
359
+ start_index = i
360
+ break
361
+ else:
362
+ raise SystemExit(f"Commit {start} not found.")
363
+
364
+ viewer = PlaybackViewer(repo, commits, start_index)
365
+ viewer.run()
366
+ finally:
367
+ if tmpdir is not None:
368
+ shutil.rmtree(tmpdir, ignore_errors=True)
learnlog/gitrepo.py ADDED
@@ -0,0 +1,171 @@
1
+ """Git repository management for learnlog.
2
+
3
+ Manages a hidden ``.learnlog/`` Git repository that tracks source code
4
+ changes and run metadata using the student's working directory as the
5
+ work tree.
6
+ """
7
+
8
+ import subprocess
9
+ import os
10
+ import pathlib
11
+ import datetime
12
+
13
+
14
+ class LearnlogRepo:
15
+ """Manages a ``.learnlog/`` Git repository.
16
+
17
+ Uses ``--git-dir=.learnlog --work-tree=.`` so the student's
18
+ working directory is the work tree. The ``.learnlog/`` directory
19
+ contains only Git internals.
20
+ """
21
+
22
+ def __init__(self, workdir):
23
+ self.workdir = pathlib.Path(workdir)
24
+ self.git_dir = self.workdir / ".learnlog"
25
+ self.env = {
26
+ **os.environ,
27
+ "GIT_DIR": str(self.git_dir),
28
+ "GIT_WORK_TREE": str(self.workdir),
29
+ }
30
+ if not self.git_dir.exists():
31
+ self.init_repo()
32
+
33
+ def git(self, *args):
34
+ """Run a Git command with the learnlog environment.
35
+
36
+ Returns the ``CompletedProcess`` object. Raises no exception
37
+ on non-zero exit codes---callers check ``returncode`` themselves.
38
+ """
39
+ return subprocess.run(
40
+ ["git"] + list(args),
41
+ cwd=str(self.workdir),
42
+ env=self.env,
43
+ capture_output=True,
44
+ text=True,
45
+ timeout=10,
46
+ )
47
+
48
+ def init_repo(self):
49
+ """Initialise a new ``.learnlog/`` Git repository.
50
+
51
+ Creates the repository, sets a fallback committer identity
52
+ (only if the student has none configured globally), sets up
53
+ exclusion patterns, and makes an initial commit of all files.
54
+ """
55
+ self.git("init")
56
+ name_check = self.git("config", "user.name")
57
+ if name_check.returncode != 0:
58
+ self.git("config", "user.name", "learnlog")
59
+ self.git("config", "user.email", "learnlog@localhost")
60
+ self.git("config", "commit.gpgsign", "false")
61
+
62
+ exclude_dir = self.git_dir / "info"
63
+ exclude_dir.mkdir(parents=True, exist_ok=True)
64
+ exclude_file = exclude_dir / "exclude"
65
+ patterns = [
66
+ ".learnlog",
67
+ "__pycache__",
68
+ "*.pyc",
69
+ ".venv",
70
+ "venv",
71
+ "env",
72
+ ]
73
+ ignore_file = self.workdir / ".learnlogignore"
74
+ if ignore_file.exists():
75
+ patterns.extend(ignore_file.read_text().splitlines())
76
+ exclude_file.write_text("\n".join(patterns) + "\n")
77
+ self.git("add", ".")
78
+ self.git(
79
+ "commit",
80
+ "--allow-empty",
81
+ "-m",
82
+ "learnlog: initial commit",
83
+ )
84
+
85
+ def begin_run(self, script, args, start_time):
86
+ """Record the start of a program run.
87
+
88
+ Stages all files. If there are changes, creates a new commit
89
+ with the run header. If no files changed, amends the previous
90
+ commit to append a new run block.
91
+
92
+ Returns the run header string (needed by :meth:`finalize_run`).
93
+ """
94
+ header = format_run_header(script, args, start_time)
95
+
96
+ self.git("add", ".")
97
+ diff = self.git("diff", "--cached", "--quiet")
98
+ has_changes = diff.returncode != 0
99
+
100
+ if has_changes:
101
+ self.git("commit", "-m", header)
102
+ else:
103
+ existing = self.git("log", "-1", "--format=%B").stdout.rstrip("\n")
104
+ combined = existing + "\n\n---\n\n" + header
105
+ self.git(
106
+ "commit",
107
+ "--amend",
108
+ "--allow-empty",
109
+ "-m",
110
+ combined,
111
+ )
112
+
113
+ return header
114
+
115
+ def finalize_run(self, io_log, exception_info, end_time, exit_code):
116
+ """Complete the current run block with results.
117
+
118
+ Amends the current commit to append I/O log, exception
119
+ information, end time, and exit code.
120
+ """
121
+ existing = self.git("log", "-1", "--format=%B").stdout.rstrip("\n")
122
+
123
+ trailer = format_run_trailer(io_log, exception_info, end_time, exit_code)
124
+ combined = existing + "\n" + trailer
125
+
126
+ self.git("add", ".")
127
+ self.git(
128
+ "commit",
129
+ "--amend",
130
+ "--allow-empty",
131
+ "-m",
132
+ combined,
133
+ )
134
+
135
+
136
+ def format_run_header(script, args, start_time):
137
+ """Format a run header block.
138
+
139
+ The header records which script was run, its arguments, and
140
+ the start time.
141
+ """
142
+ args_str = " ".join(args) if args else "(none)"
143
+ return (
144
+ f"Run: {script} {args_str}\n"
145
+ f"\n"
146
+ f"Script: {script}\n"
147
+ f"Arguments: {args_str}\n"
148
+ f"Start-Time: {start_time.isoformat()}"
149
+ )
150
+
151
+
152
+ def format_run_trailer(io_log, exception_info, end_time, exit_code):
153
+ """Format the trailer appended by :func:`finalize_run`.
154
+
155
+ Contains the end time, exit code, I/O log, and exception
156
+ information.
157
+ """
158
+ parts = [
159
+ f"End-Time: {end_time.isoformat()}",
160
+ f"Exit-Code: {exit_code}",
161
+ "",
162
+ "--- I/O ---",
163
+ io_log.rstrip("\n") if io_log else "(no I/O)",
164
+ "",
165
+ "--- exception ---",
166
+ ]
167
+ if exception_info:
168
+ parts.append(exception_info)
169
+ else:
170
+ parts.append("None")
171
+ return "\n".join(parts)
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: learnlog
3
+ Version: 0.1
4
+ Summary: Automatic logging of student code development and test runs
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Author: Daniel Bosk
8
+ Author-email: daniel@bosk.se
9
+ Maintainer: Daniel Bosk
10
+ Maintainer-email: dbosk@kth.se
11
+ Requires-Python: >=3.9
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Intended Audience :: Education
16
+ Classifier: Topic :: Education
17
+ Requires-Dist: typer (>=0.9.0)
18
+ Project-URL: Bug Tracker, https://github.com/dbosk/learnlog/issues
19
+ Project-URL: Repository, https://github.com/dbosk/learnlog
20
+ Description-Content-Type: text/markdown
21
+
22
+ # learnlog
23
+ A Python package to log students' code development and test runs
24
+
@@ -0,0 +1,9 @@
1
+ learnlog/__init__.py,sha256=QbotR1ftNkO_PlPONx5YwbfzU2EAilS5BJUwP4ckwAE,2867
2
+ learnlog/capture.py,sha256=FyBgPCgdoknru7ApQHQXwUryIzvqJrPyE7aWr0BzT1M,3723
3
+ learnlog/cli.py,sha256=eM5W8-0sggZZuhMu7h0LbCJIZmwPJPuFgmGyjHQXg98,10492
4
+ learnlog/gitrepo.py,sha256=jzTbXpJwHvMTV3aebmWIE_OOBRObyFTa74VbDpXOLGo,5185
5
+ learnlog-0.1.dist-info/METADATA,sha256=-bYg831stjxTX6vzv-ufYTx0nN5TqdiIJ78w9Pd-uPA,787
6
+ learnlog-0.1.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
7
+ learnlog-0.1.dist-info/entry_points.txt,sha256=h10KfHcC6k27VD_y6ivVUP009rNN3DuhQCfytHucIDk,45
8
+ learnlog-0.1.dist-info/licenses/LICENSE,sha256=2PATyJy5V39EFgi7-7OJEPK51uzwT7vrNR7PECdpLs0,1074
9
+ learnlog-0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ learnlog=learnlog.cli:app
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023, 2026 Daniel Bosk
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.