Keep PR #19 CI stable without user Python site-packages

The validate job was failing because the KTX helper regression suite imported optional runtime dependencies directly from the module top level. GitHub Actions does not inherit the local user site-packages that made those imports succeed on the workstation, so the test suite died before it could exercise the helper logic.

This change makes the helper import-safe in minimal environments by deferring requests usage, providing lightweight fallbacks for optional Korail/Crypto imports during unit tests, and surfacing an explicit install command when the real runtime dependencies are actually needed. The docs now list pycryptodome alongside korail2, and the regression suite forces PYTHONNOUSERSITE=1 so CI keeps exercising the dependency-light path instead of accidentally relying on a developer machine.

Constraint: PR #19 must keep npm run ci green on GitHub Actions without assuming user-level Python packages
Constraint: The KTX helper still needs the real korail2 and pycryptodome packages for live reservation flows
Rejected: Installing ad-hoc Python packages in the CI workflow | hides the import-safety regression instead of fixing the helper/test contract
Rejected: Removing the Python regression suite from skill-docs coverage | would lose the guard on the train_id reservation flow
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the KTX helper importable under PYTHONNOUSERSITE=1 and document every required runtime package in both the skill and install docs
Tested: PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p test_ktx_booking.py
Tested: node --test scripts/skill-docs.test.js
Tested: npm run ci
Not-tested: GitHub Actions validate rerun after push
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-03-27 22:51:33 +09:00
commit 1e13cc10e1
5 changed files with 116 additions and 26 deletions

View file

@ -11,7 +11,7 @@
## 먼저 필요한 것
- Python 3.10+
- `python3 -m pip install korail2`
- `python3 -m pip install korail2 pycryptodome`
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
@ -43,7 +43,7 @@
## 기본 흐름
1. `korail2` 패키지가 없으면 다른 방법으로 우회하지 말고 먼저 전역 설치한다.
1. `korail2` 또는 `pycryptodome` 패키지가 없으면 다른 방법으로 우회하지 말고 먼저 전역 설치한다.
2. `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD` 가 없으면 채팅에 붙여 넣게 하지 말고 로컬 secrets 등록 절차를 안내한다.
3. helper 로 먼저 열차를 조회한다.
4. 후보 열차의 `index`, `train_id`, 출발/도착 시각, KTX 여부, 좌석 여부를 보여준다.

View file

@ -113,7 +113,7 @@ brew install silver-flight-group/tap/kakaocli
### Python 패키지
```bash
python3 -m pip install SRTrain korail2
python3 -m pip install SRTrain korail2 pycryptodome
```
운영체제 정책이나 권한 때문에 전역 설치가 막히면, 임의의 대체 구현으로 넘어가지 말고 그 차단 사유를 사용자에게 설명한 뒤 다음 설치 단계를 정합니다.

View file

@ -1,6 +1,6 @@
---
name: ktx-booking
description: Search, reserve, inspect, and cancel KTX or Korail tickets in Korea with the korail2 Python package. Use when the user asks for KTX seats, Korail bookings, train changes, or reservation status.
description: Search, reserve, inspect, and cancel KTX or Korail tickets in Korea with the korail2 + pycryptodome Python packages. Use when the user asks for KTX seats, Korail bookings, train changes, or reservation status.
license: MIT
metadata:
category: travel
@ -32,7 +32,7 @@ metadata:
## Prerequisites
- Python 3.10+
- `python3 -m pip install korail2`
- `python3 -m pip install korail2 pycryptodome`
- `sops` and `age` installed
- common setup reviewed in `../k-skill-setup/SKILL.md`
- secret policy reviewed in `../docs/security-and-secrets.md`
@ -56,10 +56,10 @@ metadata:
### 0. Install the package globally when missing
`python3 -c 'import korail2'` 가 실패하면 다른 구현으로 우회하지 말고 전역 Python 패키지 설치를 먼저 시도한다.
`python3 -c 'import korail2, Crypto'` 가 실패하면 다른 구현으로 우회하지 말고 전역 Python 패키지 설치를 먼저 시도한다.
```bash
python3 -m pip install korail2
python3 -m pip install korail2 pycryptodome
```
### 1. Stop for secure registration when secrets are missing

View file

@ -6,29 +6,97 @@ import base64
import json
import os
import random
import re
import string
import sys
import time
from functools import reduce
import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from korail2 import (
AdultPassenger,
ChildPassenger,
Korail,
KorailError,
NeedToLoginError,
NoResultsError,
Passenger,
ReserveOption,
SeniorPassenger,
SoldOutError,
ToddlerPassenger,
TrainType,
)
import korail2.korail2 as korail_mod
try:
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
except ModuleNotFoundError as exc:
AES = None
pad = None
_CRYPTO_IMPORT_ERROR = exc
else:
_CRYPTO_IMPORT_ERROR = None
try:
from korail2 import (
AdultPassenger,
ChildPassenger,
Korail,
KorailError,
NeedToLoginError,
NoResultsError,
Passenger,
ReserveOption,
SeniorPassenger,
SoldOutError,
ToddlerPassenger,
TrainType,
)
import korail2.korail2 as korail_mod
except ModuleNotFoundError as exc:
_KORAIL_IMPORT_ERROR = exc
class KorailError(Exception):
pass
class NeedToLoginError(KorailError):
pass
class NoResultsError(KorailError):
pass
class SoldOutError(KorailError):
pass
class Passenger:
def __init__(self, count: int = 1):
self.count = count
@staticmethod
def reduce(passengers):
return passengers
def get_dict(self, _: int) -> dict[str, str]:
return {}
class AdultPassenger(Passenger):
pass
class ChildPassenger(Passenger):
pass
class ToddlerPassenger(Passenger):
pass
class SeniorPassenger(Passenger):
pass
class ReserveOption:
GENERAL_FIRST = "GENERAL_FIRST"
GENERAL_ONLY = "GENERAL_ONLY"
SPECIAL_FIRST = "SPECIAL_FIRST"
SPECIAL_ONLY = "SPECIAL_ONLY"
class TrainType:
ALL = "ALL"
KTX = "KTX"
class Korail:
def __init__(self, *args, **kwargs):
raise ModuleNotFoundError("korail2")
class _FallbackKorailModule:
EMAIL_REGEX = re.compile(r".+@.+")
PHONE_NUMBER_REGEX = re.compile(r"^\d+$")
korail_mod = _FallbackKorailModule()
else:
_KORAIL_IMPORT_ERROR = None
DEFAULT_USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 13; SM-S928N Build/UP1A.231005.007)"
DYNAPATH_PATHS = [
@ -61,6 +129,20 @@ TRAIN_ID_FIELDS = (
)
def ensure_runtime_dependencies() -> None:
missing: list[str] = []
if _KORAIL_IMPORT_ERROR is not None:
missing.append("korail2")
if _CRYPTO_IMPORT_ERROR is not None:
missing.append("pycryptodome")
if missing:
install_command = f"python3 -m pip install {' '.join(missing)}"
raise SystemExit(
"scripts/ktx_booking.py requires additional Python packages "
f"({', '.join(missing)}). Install them before running this helper: {install_command}"
)
class DynaPathMasterEngine:
APP_ID = "com.korail.talk"
AS_VALUE = "%5B38ff229cb34c7dda8e28220a2d750cce%5D"
@ -178,6 +260,8 @@ class PatchedKorail(Korail):
_device_id = "558a4f02041657ea"
def __init__(self, korail_id: str, korail_pw: str, auto_login: bool = True, want_feedback: bool = False):
import requests
self._session = requests.session()
self._session.headers.update({"User-Agent": DEFAULT_USER_AGENT})
self._engine = DynaPathMasterEngine()
@ -187,6 +271,7 @@ class PatchedKorail(Korail):
self.login(korail_id, korail_pw)
def _generate_sid(self, timestamp_ms: int) -> str:
ensure_runtime_dependencies()
plaintext = f"{self._device}{timestamp_ms}".encode("utf-8")
cipher = AES.new(self._sid_key, AES.MODE_CBC, iv=self._sid_key)
return base64.b64encode(cipher.encrypt(pad(plaintext, 16))).decode("utf-8") + "\n"
@ -535,6 +620,7 @@ def print_json(payload: dict[str, object]) -> None:
def build_client() -> PatchedKorail:
ensure_runtime_dependencies()
korail_id = os.environ.get("KSKILL_KTX_ID")
korail_pw = os.environ.get("KSKILL_KTX_PASSWORD")
if not korail_id or not korail_pw:

View file

@ -216,7 +216,11 @@ test("ktx-booking helper python regression tests pass", () => {
const result = childProcess.spawnSync(
"python3",
["-m", "unittest", "discover", "-s", "scripts", "-p", "test_ktx_booking.py"],
{ cwd: repoRoot, encoding: "utf8" },
{
cwd: repoRoot,
encoding: "utf8",
env: { ...process.env, PYTHONNOUSERSITE: "1" },
},
);
assert.equal(