콘텐츠로 이동

jussam.code_checker

jussam.code_checker

제출 코드 대조 모듈

수업에서 배포한 원본 모듈(my_logit, my_ols 등)을 학생이 직접 타이핑해 제출했을 때, 제출 파일이 원본과 어디에서 어떻게 갈라지는지 찾아 준다.

대조 기준이 되는 원본은 다음 순서로 찾는다.

1) `source_dir` 인자가 가리키는 폴더
   -> 강사가 `helpers` 를 고치는 중일 때 재배포 없이 즉시 반영된다.
2) 설치된 `jussam` 패키지 안의 같은 이름 모듈
   -> `helpers` 의 내용이 배포 시점에 패키지로 동기화되므로, 학생이
      `pip install -U jussam` 하면 최신 원본이 기준이 된다.
3) 패키지에 동봉된 지문 파일(`_fingerprints/*.py.json`)
   -> 원본 모듈을 배포에서 빼는 경우를 위한 대비책.

보고서에는 제출한 코드만 실린다. 어느 함수의 어느 줄이 갈라지는지를 학생 본인의 코드로 짚어 주되, 그 자리에 들어가야 할 원본 코드는 싣지 않는다. 따라서 학생은 고쳐야 할 지점을 정확히 알면서도 정답을 받아 적을 수는 없다. (3)번 경로는 해시만 담기므로 원본 복원 자체가 불가능하다.

대조는 세 가지를 본다.

1) 시그니처 · 기본값   `backward=False` 를 `backward=True` 로 적는 유형의 사고.
                      호출부에서 인자를 넘기지 않는 경우 조용히 결과가 달라지므로
                      가장 먼저, 가장 크게 보고한다.
2) 본문 구조          주석 · 문서화 문자열 · 공백 · 따옴표 종류를 모두 지우고
                      AST 로 정규화한 뒤 비교한다. 손으로 옮겨 적은 코드는 표기가
                      제각각이므로, 정규화 없이는 diff 가 의미를 갖지 못한다.
3) 임포트             원본이 가져오는 이름이 제출에 빠져 있는지 확인한다.

본문 차이는 다시 두 가지로 나뉜다.

구조 차이   코드의 모양 자체가 다르다. 실행 결과가 달라질 가능성이 높다.
문자열 차이 코드 모양은 같고 문자열 내용만 다르다. 대개 표기 문제지만,
            딕셔너리 키나 비교 대상 문자열이면 실행에 영향을 준다.

사용 방법 (학생)

from jussam import code_checker

r = code_checker.diff("my_logit", "1.py")   # 대조 결과를 보고서로 출력
r.show("fit_pipeline")                        # 특정 함수만 자세히 보기
r.defaults                                    # 기본값 불일치 표 (DataFrame)
r.functions                                   # 함수별 판정 표 (DataFrame)

노트북에서는 HTML 보고서로, 그 밖에서는 글자 보고서로 자동 전환된다. 형식을 직접 고르거나 파일로 남기려면 다음을 쓴다.

r.report("markdown")                          # 마크다운으로 출력
open("결과.md", "w").write(r.to_markdown())    # 파일로 저장
open("결과.html", "w").write(r.to_html())

사용 방법 (강사)

# 아직 배포되지 않은 helpers 의 최신 내용을 기준으로 점검
code_checker.diff("my_logit", "1.py", source_dir="./helpers")

# 원본 모듈을 배포에서 빼는 경우에만 필요한 지문 생성
code_checker.build("./helpers")

CompareResult

제출 파일과 원본을 대조한 결과를 담는 객체.

Attributes:

Name Type Description
module str

원본 모듈 이름.

path str

제출 파일 경로.

origin str

대조 기준으로 삼은 원본이 어디에서 왔는지.

defaults DataFrame

기본값이 다른 파라미터 표.

params DataFrame

이름 · 순서가 다른 파라미터 표.

functions DataFrame

함수별 판정 표.

imports DataFrame

임포트 불일치 표.

details dict

함수별 본문 불일치 위치 목록.

source list

제출 파일의 줄 목록. 문제 지점의 코드를 보여 줄 때 쓴다.

force bool

문자열 내용만 다른 곳까지 담았는지 여부.

suppressed int

문자열 차이라서 보고서에서 뺀 곳의 수.

Source code in jussam/code_checker.py
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
class CompareResult:
    """제출 파일과 원본을 대조한 결과를 담는 객체.

    Attributes:
        module (str): 원본 모듈 이름.
        path (str): 제출 파일 경로.
        origin (str): 대조 기준으로 삼은 원본이 어디에서 왔는지.
        defaults (DataFrame): 기본값이 다른 파라미터 표.
        params (DataFrame): 이름 · 순서가 다른 파라미터 표.
        functions (DataFrame): 함수별 판정 표.
        imports (DataFrame): 임포트 불일치 표.
        details (dict): 함수별 본문 불일치 위치 목록.
        source (list): 제출 파일의 줄 목록. 문제 지점의 코드를 보여 줄 때 쓴다.
        force (bool): 문자열 내용만 다른 곳까지 담았는지 여부.
        suppressed (int): 문자열 차이라서 보고서에서 뺀 곳의 수.
    """

    def __init__(self, module, path, fingerprint, defaults, params,
                 functions, imports, details, source, force=True, suppressed=0):
        self.module = module
        self.path = path
        self.origin = fingerprint.get("origin", "알 수 없음")
        self.defaults = defaults
        self.params = params
        self.functions = functions
        self.imports = imports
        self.details = details
        self.source = source
        self.force = force
        self.suppressed = suppressed

    # ---------------------------------------------------------
    # 요약 수치
    # ---------------------------------------------------------
    @property
    def total(self):
        """int: 원본에 들어 있는 함수의 수."""
        return int((self.functions["판정"] != "원본에 없음").sum())

    @property
    def matched(self):
        """int: 원본과 일치하는 함수의 수."""
        return int((self.functions["판정"] == "일치").sum())

    @property
    def ok(self):
        """bool: 모든 항목이 원본과 일치하는지 여부."""
        return (self.defaults.empty and self.params.empty
                and self.imports.empty and self.matched == self.total)

    @property
    def problems(self):
        """int: 본문에서 발견된 불일치 지점의 총 개수."""
        return sum(len(v) for v in self.details.values())

    # ---------------------------------------------------------
    # 보고서 출력
    # ---------------------------------------------------------
    def report(self, format=None, functions=None):
        """대조 결과를 보고서로 출력한다.

        Args:
            format (str): 'html' · 'markdown' · 'text' 중 하나. None 이면
                노트북에서는 'html', 그 밖에서는 'text' 를 쓴다 (기본값: None).
            functions (list): 보고서에 담을 함수 이름 목록. None 이면 전체 (기본값: None).
        """
        format = format or ("html" if _in_notebook() else "text")

        if format == "html":
            from IPython.display import display, HTML
            display(HTML(self.to_html(functions)))
        elif format == "markdown":
            if _in_notebook():
                from IPython.display import display, Markdown
                display(Markdown(self.to_markdown(functions)))
            else:
                print(self.to_markdown(functions))
        else:
            print(self.to_text(functions))

    def show(self, name, format=None):
        """특정 함수의 불일치 내역만 자세히 출력한다.

        Args:
            name (str): 확인할 함수 이름.
            format (str): 출력 형식 (기본값: None → 자동).
        """
        self.report(format=format, functions=[name])

    # ---------------------------------------------------------
    # 보고서 구성
    # ---------------------------------------------------------
    def _sections(self, functions=None):
        """보고서에 담을 내용을 서식과 무관한 형태로 정리한다.

        HTML · 마크다운 · 글자 보고서가 모두 이 결과를 받아 서식만 입힌다.

        Args:
            functions (list): 보고서에 담을 함수 이름 목록 (기본값: None → 전체).

        Returns:
            dict: `signature`(시그니처 불일치), `bodies`(본문 불일치),
                `imports`(임포트 불일치), `missing`/`extra`(함수 구성) 을 담은 딕셔너리.
        """
        # --- 1) 대상 함수 추리기 ---
        keys = list(self.details.keys())
        names = set(functions) if functions else None

        if names is not None:
            keys = [k for k in keys if k in names]

        def _pick(df):
            return df[df["함수"].isin(names)] if names is not None and not df.empty else df

        defaults = _pick(self.defaults)
        params = _pick(self.params)

        # --- 2) 함수별 시그니처 불일치 ---
        signature = []

        for name in sorted(set(defaults["함수"]) | set(params["함수"])) if not (
                defaults.empty and params.empty) else []:
            signature.append({
                "함수": name,
                "기본값": defaults[defaults["함수"] == name].to_dict("records"),
                "파라미터": params[params["함수"] == name].to_dict("records"),
            })

        # --- 3) 함수별 본문 불일치 (제출 코드를 함께 담는다) ---
        bodies = []

        for name in keys:
            spots = []

            for item in self.details[name]:
                # 빠진 코드는 제출 파일에 보여 줄 것이 없다.
                # -> 뒤따르는 줄의 코드를 보여 주면 그 줄이 문제인 것처럼 읽히므로
                #    자리만 알려 주고 코드는 싣지 않는다.
                missing = item["kind"] == _KIND_MISSING

                spots.append({
                    "kind": item["kind"],
                    "line": item["line"],
                    "count": item.get("count", 1),
                    "code": [] if missing else _snippet(
                        self.source, item["line"], item["end"]),
                    "before": missing,
                })

            bodies.append({"함수": name, "지점": spots})

        # --- 4) 함수 구성 (전체 보고서일 때만 의미가 있다) ---
        missing, extra = [], []

        if names is None:
            missing = self.functions[
                self.functions["판정"] == "미작성"]["함수"].tolist()
            extra = self.functions[
                self.functions["판정"] == "원본에 없음"]["함수"].tolist()

        # --- 5) 이 보고서 범위에서의 요약 (특정 함수만 볼 때는 그 함수 기준) ---
        spots = sum(len(b["지점"]) for b in bodies)

        if names is None:
            summary = (f"불일치 {self.total - self.matched}개 함수 · {spots}곳",
                       f"함수 {self.total}개 중 {self.matched}개 일치")
            ok = self.ok
        else:
            ok = not (spots or signature)
            summary = (f"불일치 {spots}곳" if spots else "본문은 원본과 일치",
                       f"대상 함수 {len(names)}개")

        # 범례는 실제로 나온 갈래만 보여 준다
        kinds = []

        for b in bodies:
            for spot in b["지점"]:
                if spot["kind"] not in kinds:
                    kinds.append(spot["kind"])

        return {
            "kinds": [k for k in _KIND_INFO if k in kinds],
            "hidden": 0 if self.force else self.suppressed,
            "signature": signature,
            "bodies": bodies,
            "imports": self.imports.to_dict("records") if names is None else [],
            "missing": missing,
            "extra": extra,
            "partial": names is not None,
            "summary": summary,
            "ok": ok,
        }

    # ---------------------------------------------------------
    # HTML 보고서
    # ---------------------------------------------------------
    def to_html(self, functions=None):
        """대조 결과를 HTML 문자열로 만든다.

        Args:
            functions (list): 보고서에 담을 함수 이름 목록 (기본값: None → 전체).

        Returns:
            str: HTML 문자열. 파일로 저장하거나 `IPython.display.HTML` 로 표시한다.
        """
        s = self._sections(functions)
        e = html.escape

        # 배경색을 지정하지 않고 반투명 색만 얹어 밝은 테마와 어두운 테마에 모두 맞춘다
        box = ("border:1px solid rgba(128,128,128,.35); border-radius:8px; "
               "padding:14px 16px; margin:0 0 14px 0;")
        mono = ("font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; "
                "font-size:12.5px;")

        out = [_syntax_css(),
               f'<div style="{mono.replace("12.5px", "13.5px")} line-height:1.55; '
               f'max-width:1000px;">']

        # --- 0) 베타 안내 ---
        out.append(
            f'<div style="border:1px solid {_BETA_COLOR}55; border-left:4px solid'
            f' {_BETA_COLOR}; border-radius:8px; padding:11px 14px; margin:0 0 14px 0;'
            f' background:{_BETA_COLOR}14;">'
            f'<div style="font-weight:600; color:{_BETA_COLOR}; margin-bottom:3px;">'
            f'&#9888;&#65039; {e(_BETA_TITLE)}</div>'
            f'<div style="font-size:12.5px; opacity:.85;">{e(_BETA_NOTICE)}</div>'
            f'</div>')

        # --- 1) 머리말 ---
        title = f"{e(self.module)} 대조 결과"
        if s["partial"]:
            title += f" — {e(', '.join(functions))}"

        badge = "✅ 원본과 일치" if s["ok"] else s["summary"][0]
        color = "#1e8e3e" if s["ok"] else "#d93025"

        out.append(
            f'<div style="{box}">'
            f'<div style="font-size:17px; font-weight:600; margin-bottom:6px;">{title}</div>'
            f'<div style="opacity:.75; font-size:12.5px;">'
            f'제출 : {e(self.path)}<br>기준 : {e(self.origin)}</div>'
            f'<div style="margin-top:10px; font-weight:600; color:{color};">{badge}</div>'
            f'<div style="opacity:.75; font-size:12.5px; margin-top:4px;">'
            f'{e(s["summary"][1])}</div>'
            f'</div>')

        if s["ok"]:
            return "".join(out) + "</div>"

        # --- 2) 시그니처 불일치 ---
        if s["signature"]:
            rows = []

            for fn in s["signature"]:
                for d in fn["기본값"]:
                    rows.append(
                        f'<tr><td style="padding:5px 12px 5px 0;">{e(fn["함수"])}'
                        f'(<b>{e(d["파라미터"])}</b>)</td>'
                        f'<td style="padding:5px 12px 5px 0; color:#d93025;">'
                        f'제출 <b>{e(str(d["제출"]))}</b></td>'
                        f'<td style="padding:5px 0; opacity:.8;">'
                        f'원본 {e(str(d["원본"]))}</td></tr>')

                for p in fn["파라미터"]:
                    rows.append(
                        f'<tr><td style="padding:5px 12px 5px 0;">{e(fn["함수"])}</td>'
                        f'<td colspan="2" style="padding:5px 0;">'
                        f'{e(p["구분"])} : <b>{e(p["파라미터"])}</b></td></tr>')

            out.append(
                f'<div style="{box}">'
                f'<div style="font-weight:600; margin-bottom:4px;">시그니처 불일치</div>'
                f'<div style="opacity:.75; font-size:12.5px; margin-bottom:10px;">'
                f'호출할 때 인자를 넘기지 않으면 원본과 다르게 동작합니다.</div>'
                f'<table style="border-collapse:collapse;">{"".join(rows)}</table></div>')

        # --- 3) 본문 불일치 (제출한 코드를 그대로 보여 준다) ---
        for fn in s["bodies"]:
            spots = []

            for spot in fn["지점"]:
                note, tone = _KIND_INFO[spot["kind"]]
                where = (f'{spot["line"]}줄 앞' if spot["before"]
                         else f'{spot["line"]}줄') if spot["line"] else "위치 확인 필요"
                label = (f'{spot["kind"]} {spot["count"]}개'
                         if spot["count"] > 1 else spot["kind"])

                if spot["code"]:
                    code = _code_html(spot["code"])
                else:
                    count = f'{spot["count"]}개' if spot["count"] > 1 else "1개"
                    code = (f'<div style="opacity:.7;">이 자리에 있어야 할 문장 '
                            f'{count}가 제출 파일에 없습니다.</div>')

                spots.append(
                    f'<div style="border-left:3px solid {tone}; padding:2px 0 2px 12px;'
                    f' margin:12px 0 0 0;">'
                    f'<div style="font-size:12.5px; margin-bottom:6px;">'
                    f'<b style="color:{tone};">{e(label)}</b>'
                    f'<span style="opacity:.6;"> · {where}</span></div>'
                    f'<div style="{mono} background:rgba(128,128,128,.10);'
                    f' border-radius:5px; padding:8px 10px; overflow-x:auto;">{code}</div>'
                    f'</div>')

            out.append(
                f'<details style="{box}" open>'
                f'<summary style="font-weight:600; cursor:pointer;">'
                f'{e(fn["함수"])} <span style="opacity:.6; font-weight:400;">'
                f'— {len(fn["지점"])}곳</span></summary>'
                f'{"".join(spots)}</details>')

        # --- 4) 임포트 · 함수 구성 ---
        extras = []

        for r in s["imports"]:
            extras.append(f'<div>{e(r["구분"])} : <b>{e(r["이름"])}</b></div>')

        if s["missing"]:
            extras.append(f'<div>제출에 없는 함수 : <b>{e(", ".join(s["missing"]))}</b></div>')

        if s["extra"]:
            extras.append(f'<div>원본에 없는 함수 : <b>{e(", ".join(s["extra"]))}</b></div>')

        if extras:
            out.append(
                f'<div style="{box}">'
                f'<div style="font-weight:600; margin-bottom:8px;">그 밖의 불일치</div>'
                f'{"".join(extras)}</div>')

        # --- 5) 범례 ---
        legend = "".join(
            f'<div style="margin-top:4px;">'
            f'<b style="color:{_KIND_INFO[k][1]};">{e(k)}</b> '
            f'<span style="opacity:.75;">— {e(_KIND_INFO[k][0])}</span></div>'
            for k in s["kinds"])

        if s["hidden"]:
            legend += (f'<div style="margin-top:10px; opacity:.75;">'
                       f'{e(_HIDDEN_HINT.format(n=s["hidden"]))}</div>')

        if legend:
            out.append(f'<div style="{box} font-size:12.5px;">{legend}</div>')
        out.append("</div>")

        return "".join(out)

    # ---------------------------------------------------------
    # 마크다운 보고서
    # ---------------------------------------------------------
    def to_markdown(self, functions=None):
        """대조 결과를 마크다운 문자열로 만든다.

        Args:
            functions (list): 보고서에 담을 함수 이름 목록 (기본값: None → 전체).

        Returns:
            str: 마크다운 문자열.
        """
        s = self._sections(functions)
        out = []

        # --- 1) 머리말 ---
        title = f"{self.module} 대조 결과"
        if s["partial"]:
            title += f" — {', '.join(functions)}"

        out.append(f"## {title}\n")
        out.append(f"> ⚠️ **{_BETA_TITLE}**  ")
        out.append(f"> {_BETA_NOTICE}\n")
        out.append(f"- 제출 : `{self.path}`")
        out.append(f"- 기준 : {self.origin}")

        if s["ok"]:
            out.append(f"\n**✅ 원본과 일치합니다.** ({s['summary'][1]})\n")

            return "\n".join(out)

        out.append(f"\n**{s['summary'][0]}** ({s['summary'][1]})\n")

        # --- 2) 시그니처 불일치 ---
        if s["signature"]:
            out.append("### 시그니처 불일치\n")
            out.append("호출할 때 인자를 넘기지 않으면 원본과 다르게 동작합니다.\n")
            out.append("| 함수 | 파라미터 | 제출 | 원본 |")
            out.append("|---|---|---|---|")

            for fn in s["signature"]:
                for d in fn["기본값"]:
                    out.append(f"| {fn['함수']} | `{d['파라미터']}` | "
                               f"`{d['제출']}` | `{d['원본']}` |")

                for p in fn["파라미터"]:
                    out.append(f"| {fn['함수']} | `{p['파라미터']}` | "
                               f"{p['구분']} | |")

            out.append("")

        # --- 3) 본문 불일치 ---
        for fn in s["bodies"]:
            out.append(f"### {fn['함수']} — {len(fn['지점'])}곳\n")

            for spot in fn["지점"]:
                where = (f"{spot['line']}줄 앞" if spot["before"]
                         else f"{spot['line']}줄") if spot["line"] else "위치 확인 필요"
                label = (f"{spot['kind']} {spot['count']}개"
                         if spot["count"] > 1 else spot["kind"])
                out.append(f"**{label}** · {where}\n")

                if spot["code"]:
                    out.append("```python")
                    out.extend(t for _, t in spot["code"])
                    out.append("```\n")
                else:
                    n = f"{spot['count']}개" if spot["count"] > 1 else "1개"
                    out.append(f"> 이 자리에 있어야 할 문장 {n}가 제출 파일에 없습니다.\n")

        # --- 4) 임포트 · 함수 구성 ---
        extras = [f"- {r['구분']} : `{r['이름']}`" for r in s["imports"]]

        if s["missing"]:
            extras.append(f"- 제출에 없는 함수 : {', '.join(s['missing'])}")

        if s["extra"]:
            extras.append(f"- 원본에 없는 함수 : {', '.join(s['extra'])}")

        if extras:
            out.append("### 그 밖의 불일치\n")
            out.extend(extras)
            out.append("")

        # --- 5) 범례 ---
        if s["kinds"] or s["hidden"]:
            out.append("---\n")

        for k in s["kinds"]:
            out.append(f"- **{k}** — {_KIND_INFO[k][0]}")

        if s["hidden"]:
            out.append(f"\n> {_HIDDEN_HINT.format(n=s['hidden'])}")

        return "\n".join(out)

    # ---------------------------------------------------------
    # 글자 보고서
    # ---------------------------------------------------------
    def to_text(self, functions=None):
        """대조 결과를 터미널용 글자 보고서로 만든다.

        Args:
            functions (list): 보고서에 담을 함수 이름 목록 (기본값: None → 전체).

        Returns:
            str: 보고서 문자열.
        """
        s = self._sections(functions)
        bar = "─" * 72
        out = []

        # --- 1) 머리말 ---
        title = f"{self.module} 대조 결과"
        if s["partial"]:
            title += f" — {', '.join(functions)}"

        out.append(f"\n{bar}\n {title}\n{bar}")
        out.append(f" ⚠️  {_BETA_TITLE}")

        # 안내 문구가 길어 화면 폭에 맞춰 접는다
        for chunk in _wrap(_BETA_NOTICE, 66):
            out.append(f"    {chunk}")

        out.append(f"{bar}")
        out.append(f" 제출 : {self.path}")
        out.append(f" 기준 : {self.origin}")

        if s["ok"]:
            out.append(f"\n ✅ 원본과 일치합니다. ({s['summary'][1]})\n")

            return "\n".join(out)

        out.append(f"\n {s['summary'][0]}   ({s['summary'][1]})")

        # --- 2) 시그니처 불일치 ---
        if s["signature"]:
            out.append(f"\n{bar}\n 시그니처 불일치"
                       f"\n 호출할 때 인자를 넘기지 않으면 원본과 다르게 동작합니다.\n")

            for fn in s["signature"]:
                for d in fn["기본값"]:
                    out.append(f"   {fn['함수']}({d['파라미터']})")
                    out.append(f"       제출 {d['제출']}   /   원본 {d['원본']}")

                for p in fn["파라미터"]:
                    out.append(f"   {fn['함수']}")
                    out.append(f"       {p['구분']} : {p['파라미터']}")

        # --- 3) 본문 불일치 ---
        for fn in s["bodies"]:
            out.append(f"\n{bar}\n {fn['함수']} — {len(fn['지점'])}곳")

            for spot in fn["지점"]:
                where = (f"{spot['line']}줄 앞" if spot["before"]
                         else f"{spot['line']}줄") if spot["line"] else "위치 확인 필요"
                label = (f"{spot['kind']} {spot['count']}개"
                         if spot["count"] > 1 else spot["kind"])
                out.append(f"\n   [{label}] {where}")

                if spot["code"]:
                    for n, t in spot["code"]:
                        out.append(f"   {str(n) + ' |' if n else '   |':>8s} {t}")
                else:
                    n = f"{spot['count']}개" if spot["count"] > 1 else "1개"
                    out.append(f"       이 자리에 있어야 할 문장 {n}가 제출 파일에 없습니다.")

        # --- 4) 임포트 · 함수 구성 ---
        extras = [f"   {r['구분']} : {r['이름']}" for r in s["imports"]]

        if s["missing"]:
            extras.append(f"   제출에 없는 함수 : {', '.join(s['missing'])}")

        if s["extra"]:
            extras.append(f"   원본에 없는 함수 : {', '.join(s['extra'])}")

        if extras:
            out.append(f"\n{bar}\n 그 밖의 불일치\n")
            out.extend(extras)

        # --- 5) 범례 ---
        if s["kinds"] or s["hidden"]:
            out.append(f"\n{bar}")

        for k in s["kinds"]:
            for i, chunk in enumerate(_wrap(f"{k} — {_KIND_INFO[k][0]}", 70)):
                out.append(f" {chunk}" if i == 0 else f"   {chunk}")

        if s["hidden"]:
            out.append("")
            for chunk in _wrap(_HIDDEN_HINT.format(n=s["hidden"]), 70):
                out.append(f" {chunk}")

        out.append("")

        return "\n".join(out)

    # `_repr_html_` 은 일부러 두지 않는다.
    # -> 노트북에서 `diff(...)` 를 대입 없이 호출하면 함수 안에서 보고서를 한 번
    #    출력하고, 셀의 마지막 값이 된 이 객체를 주피터가 한 번 더 그린다.
    #    그러면 같은 보고서가 두 번 나오므로, 여기서는 짧은 한 줄만 돌려준다.
    #    보고서를 다시 보려면 `r.report()` 를 호출한다.
    def __repr__(self):
        state = "일치" if self.ok else f"{self.total - self.matched}개 함수 불일치"

        return (f"<CompareResult {self.module} ← {Path(self.path).name} : {state}"
                f" · 다시 보려면 .report()>")

total property

total

int: 원본에 들어 있는 함수의 수.

matched property

matched

int: 원본과 일치하는 함수의 수.

ok property

ok

bool: 모든 항목이 원본과 일치하는지 여부.

problems property

problems

int: 본문에서 발견된 불일치 지점의 총 개수.

report

report(format=None, functions=None)

대조 결과를 보고서로 출력한다.

Parameters:

Name Type Description Default
format str

'html' · 'markdown' · 'text' 중 하나. None 이면 노트북에서는 'html', 그 밖에서는 'text' 를 쓴다 (기본값: None).

None
functions list

보고서에 담을 함수 이름 목록. None 이면 전체 (기본값: None).

None
Source code in jussam/code_checker.py
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
def report(self, format=None, functions=None):
    """대조 결과를 보고서로 출력한다.

    Args:
        format (str): 'html' · 'markdown' · 'text' 중 하나. None 이면
            노트북에서는 'html', 그 밖에서는 'text' 를 쓴다 (기본값: None).
        functions (list): 보고서에 담을 함수 이름 목록. None 이면 전체 (기본값: None).
    """
    format = format or ("html" if _in_notebook() else "text")

    if format == "html":
        from IPython.display import display, HTML
        display(HTML(self.to_html(functions)))
    elif format == "markdown":
        if _in_notebook():
            from IPython.display import display, Markdown
            display(Markdown(self.to_markdown(functions)))
        else:
            print(self.to_markdown(functions))
    else:
        print(self.to_text(functions))

show

show(name, format=None)

특정 함수의 불일치 내역만 자세히 출력한다.

Parameters:

Name Type Description Default
name str

확인할 함수 이름.

required
format str

출력 형식 (기본값: None → 자동).

None
Source code in jussam/code_checker.py
1119
1120
1121
1122
1123
1124
1125
1126
def show(self, name, format=None):
    """특정 함수의 불일치 내역만 자세히 출력한다.

    Args:
        name (str): 확인할 함수 이름.
        format (str): 출력 형식 (기본값: None → 자동).
    """
    self.report(format=format, functions=[name])

to_html

to_html(functions=None)

대조 결과를 HTML 문자열로 만든다.

Parameters:

Name Type Description Default
functions list

보고서에 담을 함수 이름 목록 (기본값: None → 전체).

None

Returns:

Name Type Description
str

HTML 문자열. 파일로 저장하거나 IPython.display.HTML 로 표시한다.

Source code in jussam/code_checker.py
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
def to_html(self, functions=None):
    """대조 결과를 HTML 문자열로 만든다.

    Args:
        functions (list): 보고서에 담을 함수 이름 목록 (기본값: None → 전체).

    Returns:
        str: HTML 문자열. 파일로 저장하거나 `IPython.display.HTML` 로 표시한다.
    """
    s = self._sections(functions)
    e = html.escape

    # 배경색을 지정하지 않고 반투명 색만 얹어 밝은 테마와 어두운 테마에 모두 맞춘다
    box = ("border:1px solid rgba(128,128,128,.35); border-radius:8px; "
           "padding:14px 16px; margin:0 0 14px 0;")
    mono = ("font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; "
            "font-size:12.5px;")

    out = [_syntax_css(),
           f'<div style="{mono.replace("12.5px", "13.5px")} line-height:1.55; '
           f'max-width:1000px;">']

    # --- 0) 베타 안내 ---
    out.append(
        f'<div style="border:1px solid {_BETA_COLOR}55; border-left:4px solid'
        f' {_BETA_COLOR}; border-radius:8px; padding:11px 14px; margin:0 0 14px 0;'
        f' background:{_BETA_COLOR}14;">'
        f'<div style="font-weight:600; color:{_BETA_COLOR}; margin-bottom:3px;">'
        f'&#9888;&#65039; {e(_BETA_TITLE)}</div>'
        f'<div style="font-size:12.5px; opacity:.85;">{e(_BETA_NOTICE)}</div>'
        f'</div>')

    # --- 1) 머리말 ---
    title = f"{e(self.module)} 대조 결과"
    if s["partial"]:
        title += f" — {e(', '.join(functions))}"

    badge = "✅ 원본과 일치" if s["ok"] else s["summary"][0]
    color = "#1e8e3e" if s["ok"] else "#d93025"

    out.append(
        f'<div style="{box}">'
        f'<div style="font-size:17px; font-weight:600; margin-bottom:6px;">{title}</div>'
        f'<div style="opacity:.75; font-size:12.5px;">'
        f'제출 : {e(self.path)}<br>기준 : {e(self.origin)}</div>'
        f'<div style="margin-top:10px; font-weight:600; color:{color};">{badge}</div>'
        f'<div style="opacity:.75; font-size:12.5px; margin-top:4px;">'
        f'{e(s["summary"][1])}</div>'
        f'</div>')

    if s["ok"]:
        return "".join(out) + "</div>"

    # --- 2) 시그니처 불일치 ---
    if s["signature"]:
        rows = []

        for fn in s["signature"]:
            for d in fn["기본값"]:
                rows.append(
                    f'<tr><td style="padding:5px 12px 5px 0;">{e(fn["함수"])}'
                    f'(<b>{e(d["파라미터"])}</b>)</td>'
                    f'<td style="padding:5px 12px 5px 0; color:#d93025;">'
                    f'제출 <b>{e(str(d["제출"]))}</b></td>'
                    f'<td style="padding:5px 0; opacity:.8;">'
                    f'원본 {e(str(d["원본"]))}</td></tr>')

            for p in fn["파라미터"]:
                rows.append(
                    f'<tr><td style="padding:5px 12px 5px 0;">{e(fn["함수"])}</td>'
                    f'<td colspan="2" style="padding:5px 0;">'
                    f'{e(p["구분"])} : <b>{e(p["파라미터"])}</b></td></tr>')

        out.append(
            f'<div style="{box}">'
            f'<div style="font-weight:600; margin-bottom:4px;">시그니처 불일치</div>'
            f'<div style="opacity:.75; font-size:12.5px; margin-bottom:10px;">'
            f'호출할 때 인자를 넘기지 않으면 원본과 다르게 동작합니다.</div>'
            f'<table style="border-collapse:collapse;">{"".join(rows)}</table></div>')

    # --- 3) 본문 불일치 (제출한 코드를 그대로 보여 준다) ---
    for fn in s["bodies"]:
        spots = []

        for spot in fn["지점"]:
            note, tone = _KIND_INFO[spot["kind"]]
            where = (f'{spot["line"]}줄 앞' if spot["before"]
                     else f'{spot["line"]}줄') if spot["line"] else "위치 확인 필요"
            label = (f'{spot["kind"]} {spot["count"]}개'
                     if spot["count"] > 1 else spot["kind"])

            if spot["code"]:
                code = _code_html(spot["code"])
            else:
                count = f'{spot["count"]}개' if spot["count"] > 1 else "1개"
                code = (f'<div style="opacity:.7;">이 자리에 있어야 할 문장 '
                        f'{count}가 제출 파일에 없습니다.</div>')

            spots.append(
                f'<div style="border-left:3px solid {tone}; padding:2px 0 2px 12px;'
                f' margin:12px 0 0 0;">'
                f'<div style="font-size:12.5px; margin-bottom:6px;">'
                f'<b style="color:{tone};">{e(label)}</b>'
                f'<span style="opacity:.6;"> · {where}</span></div>'
                f'<div style="{mono} background:rgba(128,128,128,.10);'
                f' border-radius:5px; padding:8px 10px; overflow-x:auto;">{code}</div>'
                f'</div>')

        out.append(
            f'<details style="{box}" open>'
            f'<summary style="font-weight:600; cursor:pointer;">'
            f'{e(fn["함수"])} <span style="opacity:.6; font-weight:400;">'
            f'— {len(fn["지점"])}곳</span></summary>'
            f'{"".join(spots)}</details>')

    # --- 4) 임포트 · 함수 구성 ---
    extras = []

    for r in s["imports"]:
        extras.append(f'<div>{e(r["구분"])} : <b>{e(r["이름"])}</b></div>')

    if s["missing"]:
        extras.append(f'<div>제출에 없는 함수 : <b>{e(", ".join(s["missing"]))}</b></div>')

    if s["extra"]:
        extras.append(f'<div>원본에 없는 함수 : <b>{e(", ".join(s["extra"]))}</b></div>')

    if extras:
        out.append(
            f'<div style="{box}">'
            f'<div style="font-weight:600; margin-bottom:8px;">그 밖의 불일치</div>'
            f'{"".join(extras)}</div>')

    # --- 5) 범례 ---
    legend = "".join(
        f'<div style="margin-top:4px;">'
        f'<b style="color:{_KIND_INFO[k][1]};">{e(k)}</b> '
        f'<span style="opacity:.75;">— {e(_KIND_INFO[k][0])}</span></div>'
        for k in s["kinds"])

    if s["hidden"]:
        legend += (f'<div style="margin-top:10px; opacity:.75;">'
                   f'{e(_HIDDEN_HINT.format(n=s["hidden"]))}</div>')

    if legend:
        out.append(f'<div style="{box} font-size:12.5px;">{legend}</div>')
    out.append("</div>")

    return "".join(out)

to_markdown

to_markdown(functions=None)

대조 결과를 마크다운 문자열로 만든다.

Parameters:

Name Type Description Default
functions list

보고서에 담을 함수 이름 목록 (기본값: None → 전체).

None

Returns:

Name Type Description
str

마크다운 문자열.

Source code in jussam/code_checker.py
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
def to_markdown(self, functions=None):
    """대조 결과를 마크다운 문자열로 만든다.

    Args:
        functions (list): 보고서에 담을 함수 이름 목록 (기본값: None → 전체).

    Returns:
        str: 마크다운 문자열.
    """
    s = self._sections(functions)
    out = []

    # --- 1) 머리말 ---
    title = f"{self.module} 대조 결과"
    if s["partial"]:
        title += f" — {', '.join(functions)}"

    out.append(f"## {title}\n")
    out.append(f"> ⚠️ **{_BETA_TITLE}**  ")
    out.append(f"> {_BETA_NOTICE}\n")
    out.append(f"- 제출 : `{self.path}`")
    out.append(f"- 기준 : {self.origin}")

    if s["ok"]:
        out.append(f"\n**✅ 원본과 일치합니다.** ({s['summary'][1]})\n")

        return "\n".join(out)

    out.append(f"\n**{s['summary'][0]}** ({s['summary'][1]})\n")

    # --- 2) 시그니처 불일치 ---
    if s["signature"]:
        out.append("### 시그니처 불일치\n")
        out.append("호출할 때 인자를 넘기지 않으면 원본과 다르게 동작합니다.\n")
        out.append("| 함수 | 파라미터 | 제출 | 원본 |")
        out.append("|---|---|---|---|")

        for fn in s["signature"]:
            for d in fn["기본값"]:
                out.append(f"| {fn['함수']} | `{d['파라미터']}` | "
                           f"`{d['제출']}` | `{d['원본']}` |")

            for p in fn["파라미터"]:
                out.append(f"| {fn['함수']} | `{p['파라미터']}` | "
                           f"{p['구분']} | |")

        out.append("")

    # --- 3) 본문 불일치 ---
    for fn in s["bodies"]:
        out.append(f"### {fn['함수']} — {len(fn['지점'])}곳\n")

        for spot in fn["지점"]:
            where = (f"{spot['line']}줄 앞" if spot["before"]
                     else f"{spot['line']}줄") if spot["line"] else "위치 확인 필요"
            label = (f"{spot['kind']} {spot['count']}개"
                     if spot["count"] > 1 else spot["kind"])
            out.append(f"**{label}** · {where}\n")

            if spot["code"]:
                out.append("```python")
                out.extend(t for _, t in spot["code"])
                out.append("```\n")
            else:
                n = f"{spot['count']}개" if spot["count"] > 1 else "1개"
                out.append(f"> 이 자리에 있어야 할 문장 {n}가 제출 파일에 없습니다.\n")

    # --- 4) 임포트 · 함수 구성 ---
    extras = [f"- {r['구분']} : `{r['이름']}`" for r in s["imports"]]

    if s["missing"]:
        extras.append(f"- 제출에 없는 함수 : {', '.join(s['missing'])}")

    if s["extra"]:
        extras.append(f"- 원본에 없는 함수 : {', '.join(s['extra'])}")

    if extras:
        out.append("### 그 밖의 불일치\n")
        out.extend(extras)
        out.append("")

    # --- 5) 범례 ---
    if s["kinds"] or s["hidden"]:
        out.append("---\n")

    for k in s["kinds"]:
        out.append(f"- **{k}** — {_KIND_INFO[k][0]}")

    if s["hidden"]:
        out.append(f"\n> {_HIDDEN_HINT.format(n=s['hidden'])}")

    return "\n".join(out)

to_text

to_text(functions=None)

대조 결과를 터미널용 글자 보고서로 만든다.

Parameters:

Name Type Description Default
functions list

보고서에 담을 함수 이름 목록 (기본값: None → 전체).

None

Returns:

Name Type Description
str

보고서 문자열.

Source code in jussam/code_checker.py
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
def to_text(self, functions=None):
    """대조 결과를 터미널용 글자 보고서로 만든다.

    Args:
        functions (list): 보고서에 담을 함수 이름 목록 (기본값: None → 전체).

    Returns:
        str: 보고서 문자열.
    """
    s = self._sections(functions)
    bar = "─" * 72
    out = []

    # --- 1) 머리말 ---
    title = f"{self.module} 대조 결과"
    if s["partial"]:
        title += f" — {', '.join(functions)}"

    out.append(f"\n{bar}\n {title}\n{bar}")
    out.append(f" ⚠️  {_BETA_TITLE}")

    # 안내 문구가 길어 화면 폭에 맞춰 접는다
    for chunk in _wrap(_BETA_NOTICE, 66):
        out.append(f"    {chunk}")

    out.append(f"{bar}")
    out.append(f" 제출 : {self.path}")
    out.append(f" 기준 : {self.origin}")

    if s["ok"]:
        out.append(f"\n ✅ 원본과 일치합니다. ({s['summary'][1]})\n")

        return "\n".join(out)

    out.append(f"\n {s['summary'][0]}   ({s['summary'][1]})")

    # --- 2) 시그니처 불일치 ---
    if s["signature"]:
        out.append(f"\n{bar}\n 시그니처 불일치"
                   f"\n 호출할 때 인자를 넘기지 않으면 원본과 다르게 동작합니다.\n")

        for fn in s["signature"]:
            for d in fn["기본값"]:
                out.append(f"   {fn['함수']}({d['파라미터']})")
                out.append(f"       제출 {d['제출']}   /   원본 {d['원본']}")

            for p in fn["파라미터"]:
                out.append(f"   {fn['함수']}")
                out.append(f"       {p['구분']} : {p['파라미터']}")

    # --- 3) 본문 불일치 ---
    for fn in s["bodies"]:
        out.append(f"\n{bar}\n {fn['함수']} — {len(fn['지점'])}곳")

        for spot in fn["지점"]:
            where = (f"{spot['line']}줄 앞" if spot["before"]
                     else f"{spot['line']}줄") if spot["line"] else "위치 확인 필요"
            label = (f"{spot['kind']} {spot['count']}개"
                     if spot["count"] > 1 else spot["kind"])
            out.append(f"\n   [{label}] {where}")

            if spot["code"]:
                for n, t in spot["code"]:
                    out.append(f"   {str(n) + ' |' if n else '   |':>8s} {t}")
            else:
                n = f"{spot['count']}개" if spot["count"] > 1 else "1개"
                out.append(f"       이 자리에 있어야 할 문장 {n}가 제출 파일에 없습니다.")

    # --- 4) 임포트 · 함수 구성 ---
    extras = [f"   {r['구분']} : {r['이름']}" for r in s["imports"]]

    if s["missing"]:
        extras.append(f"   제출에 없는 함수 : {', '.join(s['missing'])}")

    if s["extra"]:
        extras.append(f"   원본에 없는 함수 : {', '.join(s['extra'])}")

    if extras:
        out.append(f"\n{bar}\n 그 밖의 불일치\n")
        out.extend(extras)

    # --- 5) 범례 ---
    if s["kinds"] or s["hidden"]:
        out.append(f"\n{bar}")

    for k in s["kinds"]:
        for i, chunk in enumerate(_wrap(f"{k} — {_KIND_INFO[k][0]}", 70)):
            out.append(f" {chunk}" if i == 0 else f"   {chunk}")

    if s["hidden"]:
        out.append("")
        for chunk in _wrap(_HIDDEN_HINT.format(n=s["hidden"]), 70):
            out.append(f" {chunk}")

    out.append("")

    return "\n".join(out)

analyze_source

analyze_source(source, module=None)

파이썬 소스코드를 읽어 지문 딕셔너리를 만든다.

소스코드를 실행하지 않고 구문만 해석하므로, 임포트가 깨져 있거나 실행에 부작용이 있는 파일도 안전하게 분석할 수 있다.

Parameters:

Name Type Description Default
source str

분석할 파이썬 소스코드.

required
module str

지문에 기록할 모듈 이름 (기본값: None).

None

Returns:

Name Type Description
dict

지문 딕셔너리. 함수별 시그니처 · 해시 · 문장 해시를 담는다.

Raises:

Type Description
SyntaxError

소스코드에 구문 오류가 있는 경우.

Source code in jussam/code_checker.py
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
def analyze_source(source, module=None):
    """파이썬 소스코드를 읽어 지문 딕셔너리를 만든다.

    소스코드를 실행하지 않고 구문만 해석하므로, 임포트가 깨져 있거나 실행에
    부작용이 있는 파일도 안전하게 분석할 수 있다.

    Args:
        source (str): 분석할 파이썬 소스코드.
        module (str): 지문에 기록할 모듈 이름 (기본값: None).

    Returns:
        dict: 지문 딕셔너리. 함수별 시그니처 · 해시 · 문장 해시를 담는다.

    Raises:
        SyntaxError: 소스코드에 구문 오류가 있는 경우.
    """
    # --- 1) 구문 해석 후 문서화 문자열 제거 ---
    tree = ast.parse(source)
    tree = _StripDocstring().visit(tree)
    ast.fix_missing_locations(tree)

    # --- 2) 최상위 함수와 클래스 메서드를 모두 수집 ---
    functions = {}
    targets = []

    for order, node in enumerate(tree.body):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            targets.append((node.name, node, order))
        elif isinstance(node, ast.ClassDef):
            # 클래스 메서드는 'ClassName.method' 이름으로 기록한다
            for m in node.body:
                if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    targets.append((f"{node.name}.{m.name}", m, order))

    # --- 3) 함수마다 시그니처 · 전체 해시 · 문장 해시를 기록 ---
    for name, node, order in targets:
        functions[name] = {
            "order": order,
            "line": getattr(node, "lineno", 0),
            "code": _hash(_normalize(node)),        # 시그니처를 포함한 전체 해시
            "shape": _hash(_shape(node)),           # 문자열을 가린 전체 해시
            "params": _read_params(node.args),
            "statements": _read_statements(node),
        }

    return {
        "schema": SCHEMA_VERSION,
        "module": module,
        "generated_at": datetime.now().isoformat(timespec="seconds"),
        "imports": _read_imports(tree),
        "functions": functions,
    }

analyze_file

analyze_file(path, module=None)

파이썬 파일을 읽어 지문 딕셔너리를 만든다.

Parameters:

Name Type Description Default
path str

분석할 파이썬 파일 경로.

required
module str

지문에 기록할 모듈 이름. None 이면 파일명을 사용한다 (기본값: None).

None

Returns:

Name Type Description
dict

지문 딕셔너리.

Raises:

Type Description
FileNotFoundError

파일이 없는 경우.

Source code in jussam/code_checker.py
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
def analyze_file(path, module=None):
    """파이썬 파일을 읽어 지문 딕셔너리를 만든다.

    Args:
        path (str): 분석할 파이썬 파일 경로.
        module (str): 지문에 기록할 모듈 이름. None 이면 파일명을 사용한다 (기본값: None).

    Returns:
        dict: 지문 딕셔너리.

    Raises:
        FileNotFoundError: 파일이 없는 경우.
    """
    p = Path(path)

    if not p.exists():
        raise FileNotFoundError(f"파일을 찾을 수 없습니다: {path}")

    return analyze_source(p.read_text(encoding="utf-8"), module=module or p.stem)

build

build(src, out=None, modules=None, verbose=True)

원본 폴더의 모듈들을 훑어 지문 파일을 생성한다.

helpers 의 모듈이 패키지로 동기화되어 배포된다면 이 단계는 필요하지 않다. 설치된 모듈 자체가 대조 기준이 되기 때문이다. 원본 모듈을 배포에서 빼야 하는 경우에만 사용한다. 생성되는 것은 해시와 시그니처뿐이라 원본 코드는 담기지 않는다.

Parameters:

Name Type Description Default
src str

원본 모듈이 들어 있는 폴더 경로 (예: './helpers').

required
out str

지문을 저장할 폴더. None 이면 패키지 안의 _fingerprints (기본값: None).

None
modules list

지문을 만들 모듈 이름 목록. None 이면 my_*.py 전체 (기본값: None).

None
verbose bool

진행 내역 출력 여부 (기본값: True).

True

Returns:

Name Type Description
list

생성된 지문 파일 경로의 목록.

Raises:

Type Description
NotADirectoryError

원본 폴더가 없는 경우.

Source code in jussam/code_checker.py
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
def build(src, out=None, modules=None, verbose=True):
    """원본 폴더의 모듈들을 훑어 지문 파일을 생성한다.

    `helpers` 의 모듈이 패키지로 동기화되어 배포된다면 이 단계는 필요하지 않다.
    설치된 모듈 자체가 대조 기준이 되기 때문이다. 원본 모듈을 배포에서 빼야 하는
    경우에만 사용한다. 생성되는 것은 해시와 시그니처뿐이라 원본 코드는 담기지 않는다.

    Args:
        src (str): 원본 모듈이 들어 있는 폴더 경로 (예: './helpers').
        out (str): 지문을 저장할 폴더. None 이면 패키지 안의 `_fingerprints` (기본값: None).
        modules (list): 지문을 만들 모듈 이름 목록. None 이면 `my_*.py` 전체 (기본값: None).
        verbose (bool): 진행 내역 출력 여부 (기본값: True).

    Returns:
        list: 생성된 지문 파일 경로의 목록.

    Raises:
        NotADirectoryError: 원본 폴더가 없는 경우.
    """
    # --- 1) 원본 폴더 확인 ---
    src_dir = Path(src)

    if not src_dir.is_dir():
        raise NotADirectoryError(f"원본 폴더를 찾을 수 없습니다: {src}")

    out_dir = Path(out) if out else FINGERPRINT_DIR
    out_dir.mkdir(parents=True, exist_ok=True)

    # --- 2) 대상 모듈 결정 ---
    if modules:
        files = [src_dir / f"{m}.py" for m in modules]
    else:
        files = sorted(src_dir.glob("my_*.py"))

    # --- 3) 모듈마다 지문 생성 ---
    created = []

    for f in files:
        if not f.exists():
            print(f"⚠ 건너뜀 (파일 없음): {f}")
            continue

        try:
            fingerprint = analyze_file(f, module=f.stem)
        except SyntaxError as e:
            print(f"⚠ 건너뜀 (구문 오류): {f} → {e.lineno}줄 {e.msg}")
            continue

        target = out_dir / f"{f.stem}.json"
        target.write_text(
            json.dumps(fingerprint, ensure_ascii=False, indent=1), encoding="utf-8")
        created.append(str(target))

        if verbose:
            print(f"✓ {f.stem:20s} 함수 {len(fingerprint['functions']):3d}개 → {target.name}")

    if verbose:
        print(f"\n지문 {len(created)}개 생성 완료 : {out_dir}")

    return created

load_fingerprint

load_fingerprint(module, source_dir=None)

대조 기준이 되는 원본의 지문을 불러온다.

원본은 원본 폴더 → 설치된 패키지 모듈 → 동봉 지문 순서로 찾는다. 앞의 두 경로는 소스에서 그 자리에 지문을 만들어 쓰므로, helpers 를 고치고 배포하면 별도의 지문 생성 없이 그대로 반영된다.

Parameters:

Name Type Description Default
module str

원본 모듈 이름 (예: 'my_logit'). 'jussam.my_logit' 처럼 패키지 이름이 붙어 있어도 된다.

required
source_dir str

원본 폴더 경로. 지정하면 이 폴더를 최우선으로 참조한다 (기본값: None).

None

Returns:

Name Type Description
dict

지문 딕셔너리. 어느 경로에서 왔는지가 origin 키에 담긴다.

Raises:

Type Description
FileNotFoundError

어느 경로에서도 원본을 찾지 못한 경우.

Source code in jussam/code_checker.py
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
def load_fingerprint(module, source_dir=None):
    """대조 기준이 되는 원본의 지문을 불러온다.

    원본은 `원본 폴더 → 설치된 패키지 모듈 → 동봉 지문` 순서로 찾는다.
    앞의 두 경로는 소스에서 그 자리에 지문을 만들어 쓰므로, `helpers` 를 고치고
    배포하면 별도의 지문 생성 없이 그대로 반영된다.

    Args:
        module (str): 원본 모듈 이름 (예: 'my_logit'). 'jussam.my_logit' 처럼
            패키지 이름이 붙어 있어도 된다.
        source_dir (str): 원본 폴더 경로. 지정하면 이 폴더를 최우선으로 참조한다
            (기본값: None).

    Returns:
        dict: 지문 딕셔너리. 어느 경로에서 왔는지가 `origin` 키에 담긴다.

    Raises:
        FileNotFoundError: 어느 경로에서도 원본을 찾지 못한 경우.
    """
    module = module.split(".")[-1]      # 'jussam.my_logit' → 'my_logit'

    # --- 1) 원본 폴더 (강사가 helpers 를 고치는 중일 때) ---
    live = _source_dir(source_dir)

    if live and (live / f"{module}.py").exists():
        target = live / f"{module}.py"
        fingerprint = analyze_file(target, module=module)
        fingerprint["origin"] = f"원본 폴더 {target}"
        return fingerprint

    # --- 2) 설치된 패키지 안의 원본 모듈 (배포 시 helpers 와 동기화된다) ---
    installed = _installed_module_path(module)

    if installed:
        fingerprint = analyze_file(installed, module=module)
        fingerprint["origin"] = f"설치된 {PACKAGE_NAME} {_package_version()} 모듈"

        # 동봉 지문이 함께 있는데 내용이 어긋나면 동기화가 밀린 것이다
        _warn_if_stale(module, fingerprint)

        return fingerprint

    # --- 3) 동봉된 지문 (원본 모듈을 배포에서 뺀 경우) ---
    path = FINGERPRINT_DIR / f"{module}.json"

    if not path.exists():
        available = list_modules()
        raise FileNotFoundError(
            f"'{module}' 의 원본을 찾을 수 없습니다.\n"
            f"대조할 수 있는 모듈: {', '.join(available) if available else '없음'}")

    fingerprint = json.loads(path.read_text(encoding="utf-8"))
    fingerprint["origin"] = f"동봉 지문 {fingerprint.get('generated_at', '')[:10]} 기준"

    return fingerprint

list_modules

list_modules()

대조할 수 있는 모듈 이름의 목록을 돌려준다.

설치된 패키지의 my_*.py 모듈과 동봉된 지문을 합쳐서 돌려준다.

Returns:

Name Type Description
list

대조 가능한 모듈 이름 목록.

Source code in jussam/code_checker.py
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
def list_modules():
    """대조할 수 있는 모듈 이름의 목록을 돌려준다.

    설치된 패키지의 `my_*.py` 모듈과 동봉된 지문을 합쳐서 돌려준다.

    Returns:
        list: 대조 가능한 모듈 이름 목록.
    """
    names = {p.stem for p in MODULE_DIR.glob("my_*.py")}
    names.discard(Path(__file__).stem)      # 대조 도구 자신은 제외

    if FINGERPRINT_DIR.is_dir():
        names.update(p.stem for p in FINGERPRINT_DIR.glob("*.json"))

    return sorted(names)

diff

diff(
    module,
    path,
    source_dir=None,
    report=True,
    progress=True,
    force=False,
)

제출 파일을 원본 모듈의 지문과 대조한다.

제출 파일은 실행하지 않고 구문만 해석하므로, 임포트가 깨져 있거나 실행 시 부작용이 있는 파일도 안전하게 대조할 수 있다.

Parameters:

Name Type Description Default
module str

원본 모듈 이름 (예: 'my_logit', 'my_ols').

required
path str

제출한 파이썬 파일의 경로.

required
source_dir str

원본 폴더 경로. 지정하면 동봉 지문 대신 이 폴더를 실시간으로 참조한다. 강사용 (기본값: None).

None
report bool

대조 결과를 바로 출력할지 여부 (기본값: True).

True
progress bool

진행률 표시줄을 보여 줄지 여부 (기본값: True).

True
force bool

문자열 내용만 다른 곳까지 함께 보고할지 여부 (기본값: False). 대부분은 출력 문구의 표기 차이라 실행에 영향이 없으므로 기본으로는 빼고 보여 준다. 딕셔너리 키나 비교 대상 문자열까지 훑어보려면 True. 시그니처의 기본값은 이 설정과 무관하게 항상 대조한다.

False

Returns:

Name Type Description
CompareResult

대조 결과 객체.

Raises:

Type Description
FileNotFoundError

제출 파일이나 모듈 지문이 없는 경우.

SyntaxError

제출 파일에 구문 오류가 있는 경우.

Source code in jussam/code_checker.py
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
def diff(module, path, source_dir=None, report=True, progress=True, force=False):
    """제출 파일을 원본 모듈의 지문과 대조한다.

    제출 파일은 실행하지 않고 구문만 해석하므로, 임포트가 깨져 있거나 실행 시
    부작용이 있는 파일도 안전하게 대조할 수 있다.

    Args:
        module (str): 원본 모듈 이름 (예: 'my_logit', 'my_ols').
        path (str): 제출한 파이썬 파일의 경로.
        source_dir (str): 원본 폴더 경로. 지정하면 동봉 지문 대신 이 폴더를
            실시간으로 참조한다. 강사용 (기본값: None).
        report (bool): 대조 결과를 바로 출력할지 여부 (기본값: True).
        progress (bool): 진행률 표시줄을 보여 줄지 여부 (기본값: True).
        force (bool): 문자열 내용만 다른 곳까지 함께 보고할지 여부 (기본값: False).
            대부분은 출력 문구의 표기 차이라 실행에 영향이 없으므로 기본으로는
            빼고 보여 준다. 딕셔너리 키나 비교 대상 문자열까지 훑어보려면 True.
            시그니처의 기본값은 이 설정과 무관하게 항상 대조한다.

    Returns:
        CompareResult: 대조 결과 객체.

    Raises:
        FileNotFoundError: 제출 파일이나 모듈 지문이 없는 경우.
        SyntaxError: 제출 파일에 구문 오류가 있는 경우.
    """
    target = Path(path)

    if not target.exists():
        raise FileNotFoundError(f"파일을 찾을 수 없습니다: {path}")

    # 준비 단계만 먼저 잡고, 함수 수는 대조를 시작할 때 더한다
    bar = _Progress(total=4, enabled=progress)

    try:
        # --- 1) 원본 지문과 제출 파일의 지문을 준비 ---
        bar.step("원본 분석 중", 0)
        fingerprint = load_fingerprint(module, source_dir=source_dir)
        bar.step("제출 파일 읽는 중")

        # 보고서에서 문제 지점의 코드를 그대로 보여 주려면 원문이 필요하다
        source = target.read_text(encoding="utf-8")

        try:
            submitted = analyze_source(source, module=target.stem)
        except SyntaxError as e:
            raise SyntaxError(
                f"제출 파일에 구문 오류가 있어 대조할 수 없습니다.\n"
                f"  {path}:{e.lineno}줄 → {e.msg}") from None

        bar.step("함수 대조 중")
        result = _build_result(module, path, fingerprint, submitted, source,
                               bar, force)
    finally:
        bar.close()

    if report:
        result.report()

    return result