SessionSmith 2.0.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/PKG-INFO +89 -6
  2. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/__init__.py +40 -1
  3. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/core.py +9 -4
  4. sessionsmith-2.2.0/SessionSmith/crypto.py +205 -0
  5. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/error_handling.py +1 -1
  6. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/exceptions.py +44 -2
  7. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/i18n.py +5 -3
  8. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/info.py +0 -1
  9. sessionsmith-2.2.0/SessionSmith/locking.py +385 -0
  10. sessionsmith-2.2.0/SessionSmith/logging_config.py +194 -0
  11. sessionsmith-2.2.0/SessionSmith/remote_backends.py +326 -0
  12. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/ssm.py +1122 -414
  13. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/tracer.py +2 -2
  14. sessionsmith-2.2.0/SessionSmith/validation.py +158 -0
  15. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/PKG-INFO +89 -6
  16. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/SOURCES.txt +15 -1
  17. sessionsmith-2.2.0/SessionSmith.egg-info/requires.txt +32 -0
  18. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/pyproject.toml +67 -1
  19. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/readme.md +75 -0
  20. sessionsmith-2.2.0/setup.py +4 -0
  21. sessionsmith-2.2.0/tests/test_crypto.py +95 -0
  22. sessionsmith-2.2.0/tests/test_locking.py +454 -0
  23. sessionsmith-2.2.0/tests/test_logging_config.py +106 -0
  24. sessionsmith-2.2.0/tests/test_merge_checkout_features.py +224 -0
  25. sessionsmith-2.2.0/tests/test_remote_backends.py +166 -0
  26. sessionsmith-2.2.0/tests/test_security.py +243 -0
  27. sessionsmith-2.2.0/tests/test_ssm_bugfixes.py +239 -0
  28. sessionsmith-2.2.0/tests/test_ssm_e2e.py +466 -0
  29. sessionsmith-2.2.0/tests/test_verify.py +66 -0
  30. sessionsmith-2.0.0/SessionSmith.egg-info/requires.txt +0 -15
  31. sessionsmith-2.0.0/setup.py +0 -71
  32. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/LICENSE +0 -0
  33. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/cli.py +0 -0
  34. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/compare.py +0 -0
  35. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/formats.py +0 -0
  36. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/jupyter_utils.py +0 -0
  37. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/manager.py +0 -0
  38. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/py.typed +0 -0
  39. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/resource_manager.py +0 -0
  40. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/serializers.py +0 -0
  41. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/utils.py +0 -0
  42. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/visualizer.py +0 -0
  43. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/visualizer_arrays.py +0 -0
  44. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith/visualizer_generic.py +0 -0
  45. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/dependency_links.txt +0 -0
  46. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/entry_points.txt +0 -0
  47. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/SessionSmith.egg-info/top_level.txt +0 -0
  48. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/setup.cfg +0 -0
  49. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/tests/test_cli.py +0 -0
  50. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/tests/test_core.py +0 -0
  51. {sessionsmith-2.0.0 → sessionsmith-2.2.0}/tests/test_ssm.py +0 -0
@@ -1,9 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SessionSmith
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: Git-style session management for Python. Save, restore, and track your variables with ease.
5
- Home-page: https://github.com/yut0takagi/SessionSmith
6
- Author: YutoTAKAGI
7
5
  Author-email: YutoTAKAGI <yutotkg.1040@gmail.com>
8
6
  License: MIT
9
7
  Project-URL: Homepage, https://github.com/yut0takagi/SessionSmith
@@ -33,6 +31,15 @@ Description-Content-Type: text/markdown
33
31
  License-File: LICENSE
34
32
  Provides-Extra: visualization
35
33
  Requires-Dist: matplotlib>=3.5.0; extra == "visualization"
34
+ Provides-Extra: crypto
35
+ Requires-Dist: cryptography>=3.4; extra == "crypto"
36
+ Provides-Extra: s3
37
+ Requires-Dist: boto3>=1.26; extra == "s3"
38
+ Provides-Extra: gcs
39
+ Requires-Dist: google-cloud-storage>=2.0; extra == "gcs"
40
+ Provides-Extra: cloud
41
+ Requires-Dist: boto3>=1.26; extra == "cloud"
42
+ Requires-Dist: google-cloud-storage>=2.0; extra == "cloud"
36
43
  Provides-Extra: dev
37
44
  Requires-Dist: pytest>=7.0.0; extra == "dev"
38
45
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -41,12 +48,13 @@ Requires-Dist: black>=23.0.0; extra == "dev"
41
48
  Requires-Dist: isort>=5.12.0; extra == "dev"
42
49
  Requires-Dist: mypy>=1.0.0; extra == "dev"
43
50
  Requires-Dist: ruff>=0.1.0; extra == "dev"
51
+ Requires-Dist: cryptography>=3.4; extra == "dev"
44
52
  Provides-Extra: all
45
53
  Requires-Dist: matplotlib>=3.5.0; extra == "all"
46
- Dynamic: author
47
- Dynamic: home-page
54
+ Requires-Dist: cryptography>=3.4; extra == "all"
55
+ Requires-Dist: boto3>=1.26; extra == "all"
56
+ Requires-Dist: google-cloud-storage>=2.0; extra == "all"
48
57
  Dynamic: license-file
49
- Dynamic: requires-python
50
58
 
51
59
  # SessionSmith
52
60
 
@@ -78,6 +86,9 @@ Dynamic: requires-python
78
86
  - 🚀 **拡張機能対応**: Cursor/VSCode拡張機能でコードを書かずに実行可能
79
87
  - 🌐 **多言語対応**: 日本語・英語のエラーメッセージに対応
80
88
  - 🛡️ **堅牢なエラーハンドリング**: リトライ、詳細なエラー情報、コンテキスト管理
89
+ - ☁️ **クラウドリモート**(v2.1.0): S3 / GCS / HTTP へ `push` / `pull`
90
+ - 🔐 **暗号化・改ざん検出**(v2.1.0): 認証付き暗号でのエクスポート、HMAC 署名による検証
91
+ - 📝 **構造化ロギング**(v2.1.0): ログレベル・ファイル出力・JSON ログ
81
92
 
82
93
  ## インストール
83
94
 
@@ -105,6 +116,18 @@ pip install SessionSmith[visualization]
105
116
  pip install matplotlib
106
117
  ```
107
118
 
119
+ オプション機能(v2.1.0):
120
+
121
+ ```bash
122
+ pip install SessionSmith[crypto] # 暗号化(cryptography)
123
+ pip install SessionSmith[s3] # S3 リモート(boto3)
124
+ pip install SessionSmith[gcs] # GCS リモート(google-cloud-storage)
125
+ pip install SessionSmith[cloud] # S3 + GCS
126
+ pip install SessionSmith[all] # すべて
127
+ ```
128
+
129
+ > 署名(HMAC)・構造化ロギングは追加依存なしで利用できます。
130
+
108
131
  ## クイックスタート
109
132
 
110
133
  ### SSM - Git風セッション管理(推奨)
@@ -228,6 +251,61 @@ lang = get_language() # 'ja' または 'en'
228
251
  エラーメッセージや情報メッセージが設定した言語で表示されます。
229
252
  SSMが初期化されている場合、言語設定は自動的に `.ssm/config` に保存されます。
230
253
 
254
+ ### クラウド / URL リモート(v2.1.0)
255
+
256
+ ```python
257
+ from SessionSmith import ssm
258
+
259
+ ssm.init()
260
+ ssm.commit("experiment v1")
261
+
262
+ # クラウドリモートを登録して push / pull
263
+ ssm.remote_add("cloud", "s3://my-bucket/experiments") # S3
264
+ # ssm.remote_add("cloud", "gs://my-bucket/experiments") # GCS
265
+ # ssm.remote_add("cloud", "file:///shared/ssm-remote") # 共有ディレクトリ
266
+ ssm.push("cloud", "main")
267
+
268
+ # 別マシン / 別リポジトリから取得
269
+ ssm.pull("cloud", "main")
270
+
271
+ # HTTP(S) 越しの読み取り(pull のみ)
272
+ ssm.remote_add("mirror", "https://example.com/ssm-repo")
273
+ ssm.pull("mirror", "main")
274
+ ```
275
+
276
+ ### 暗号化・改ざん検出(v2.1.0)
277
+
278
+ ```python
279
+ from SessionSmith import ssm
280
+
281
+ # --- 暗号化(要 pip install SessionSmith[crypto])---
282
+ ssm.export("backup.pkl", password="my-secret") # 暗号化してエクスポート
283
+ ssm.import_session("backup.pkl", password="my-secret") # 復号してインポート
284
+ ssm.push("cloud", "main", password="my-secret") # リモート上のデータを暗号化
285
+
286
+ # --- 改ざん検出(HMAC 署名・追加依存なし)---
287
+ ssm.config("sign_key", "team-secret") # 署名鍵を設定(環境変数 SESSIONSMITH_SIGN_KEY でも可)
288
+ ssm.commit("signed snapshot") # 以降のコミットに署名が付与される
289
+
290
+ result = ssm.verify() # 整合性(再ハッシュ)と署名を検証
291
+ # {'integrity_ok': True, 'signed': True, 'signature_ok': True, 'issues': []}
292
+ ```
293
+
294
+ ### 構造化ロギング(v2.1.0)
295
+
296
+ ```python
297
+ from SessionSmith import setup_logging, enable_debug, set_log_level
298
+
299
+ setup_logging(level="INFO", log_file="ssm.log") # ファイル出力(ローテーション付き)
300
+ setup_logging(level="INFO", json_format=True) # JSON 構造化ログ
301
+ enable_debug() # デバッグモード
302
+
303
+ # 環境変数でも設定可能:
304
+ # SESSIONSMITH_LOG_LEVEL=DEBUG
305
+ # SESSIONSMITH_LOG_FILE=ssm.log
306
+ # SESSIONSMITH_LOG_JSON=1
307
+ ```
308
+
231
309
  ### レガシーAPI(後方互換性)
232
310
 
233
311
  > ⚠️ 以下のAPIは後方互換性のために残されています。新規開発では `ssm` の使用を推奨します。
@@ -341,6 +419,7 @@ Python notebookで複数のファイルからインポートしたり、複数
341
419
  - 📈 [アルゴリズムトレーサー](docs/algorithm-tracer.md) - トレース・可視化機能
342
420
  - 🌐 [国際化(i18n)ガイド](docs/i18n-guide.md) - 多言語対応(日本語・英語)
343
421
  - 📚 [APIリファレンス](docs/api-reference.md) - 全APIの詳細
422
+ - ⏱️ [ベンチマーク](benchmarks/README.md) - 大規模セッション・チェックポイント・メモリ計測(issue #31)
344
423
 
345
424
  ## 使用例
346
425
 
@@ -407,6 +486,10 @@ SessionSmithには、Cursor/VSCode用の拡張機能が用意されています
407
486
 
408
487
  ### 機能
409
488
 
489
+ - 🌳 **Session Graph(v0.2.0〜)**: `.ssm/` のコミット履歴を gitgraph 風に可視化。
490
+ ブランチのレーン色分け、マージの分岐・合流、ブランチ/タグ/HEADバッジ、コミット詳細
491
+ (変数一覧・署名状態)、GUI からの Checkout / Branch / Tag / Commit に対応
492
+ - 🗂 **Sessions ビュー(v0.2.0〜)**: アクティビティバーにブランチ・タグ・コミットのツリー表示
410
493
  - ✅ **Save Session**: 現在のPythonセッション(変数)を保存
411
494
  - ✅ **Load Session**: セッションファイルを選択して変数を復元
412
495
  - ✅ **Show Session Info**: セッションファイルの情報を表示
@@ -39,6 +39,16 @@ from .compare import compare_sessions, print_comparison
39
39
 
40
40
  # 後方互換性のためのAPI(非推奨)
41
41
  from .core import load_session, save_session
42
+
43
+ # セキュリティ(暗号化・署名)
44
+ from .crypto import (
45
+ HAS_CRYPTOGRAPHY,
46
+ CryptoError,
47
+ decrypt_data,
48
+ encrypt_data,
49
+ sign_data,
50
+ verify_signature,
51
+ )
42
52
  from .error_handling import (
43
53
  ErrorHandler,
44
54
  error_context,
@@ -76,6 +86,17 @@ from .exceptions import (
76
86
  )
77
87
  from .i18n import Language, get_language, set_language, t, translate
78
88
  from .info import get_session_info, list_session_variables, print_session_info
89
+
90
+ # ロギング設定
91
+ from .logging_config import (
92
+ configure_from_env as _configure_logging_from_env,
93
+ )
94
+ from .logging_config import (
95
+ enable_debug,
96
+ get_log_level,
97
+ set_log_level,
98
+ setup_logging,
99
+ )
79
100
  from .manager import SessionManager
80
101
  from .serializers import CustomSerializer
81
102
  from .ssm import (
@@ -97,7 +118,13 @@ from .tracer import AlgorithmTracer
97
118
  from .utils import verify_session
98
119
  from .visualizer import print_trace_summary, visualize_algorithm_trace
99
120
 
100
- __version__ = "2.0.0"
121
+ __version__ = "2.2.0"
122
+
123
+ # 環境変数からロギングを自動設定(SESSIONSMITH_LOG_LEVEL / SESSIONSMITH_LOG_FILE)
124
+ try:
125
+ _configure_logging_from_env()
126
+ except Exception: # pragma: no cover - ロギング設定失敗で import を妨げない
127
+ pass
101
128
 
102
129
  __all__ = [
103
130
  # 主要API(推奨)
@@ -134,6 +161,18 @@ __all__ = [
134
161
  "ErrorHandler",
135
162
  "set_default_error_handler",
136
163
  "get_default_error_handler",
164
+ # ロギング
165
+ "setup_logging",
166
+ "set_log_level",
167
+ "get_log_level",
168
+ "enable_debug",
169
+ # セキュリティ(暗号化・署名)
170
+ "encrypt_data",
171
+ "decrypt_data",
172
+ "sign_data",
173
+ "verify_signature",
174
+ "CryptoError",
175
+ "HAS_CRYPTOGRAPHY",
137
176
  # ブランチ・マージ・タグ・リモート機能
138
177
  "branch",
139
178
  "checkout_branch",
@@ -46,9 +46,14 @@ def _validate_file_path(file_path: Union[str, Path]) -> Path:
46
46
  if not isinstance(file_path, (str, Path)):
47
47
  raise TypeError(f"file_path must be str or Path, got {type(file_path).__name__}")
48
48
 
49
- if not str(file_path).strip():
49
+ path_str = str(file_path)
50
+
51
+ if not path_str.strip():
50
52
  raise ValueError("file_path cannot be empty")
51
53
 
54
+ if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in path_str):
55
+ raise ValueError("file_path must not contain control characters")
56
+
52
57
  return Path(file_path)
53
58
 
54
59
 
@@ -540,11 +545,11 @@ def load_session(
540
545
  session = {k: v for k, v in session.items() if k not in exclude}
541
546
 
542
547
  # グローバル変数に更新
543
- loaded_vars: list[str] = []
548
+ loaded_names: list[str] = []
544
549
  for k, v in session.items():
545
550
  try:
546
551
  globals_dict[k] = v
547
- loaded_vars.append(k)
552
+ loaded_names.append(k)
548
553
  if verbose:
549
554
  print(f"Loaded variable: {k} ({type(v).__name__})")
550
555
  except Exception as e:
@@ -552,6 +557,6 @@ def load_session(
552
557
  warnings.warn(f"Failed to load variable '{k}': {str(e)}", UserWarning, stacklevel=2)
553
558
 
554
559
  if verbose:
555
- print(f"Loaded {len(loaded_vars)} variables")
560
+ print(f"Loaded {len(loaded_names)} variables")
556
561
 
557
562
  return session
@@ -0,0 +1,205 @@
1
+ """
2
+ 暗号化・署名モジュール(セキュリティ機能)
3
+
4
+ このモジュールは2つの独立した機能を提供します:
5
+
6
+ 1. **整合性・改ざん検出(署名)**: HMAC-SHA256 を使用。Python標準ライブラリ
7
+ のみで動作し、追加の依存関係は不要です。コミットやエクスポートしたデータの
8
+ 改ざんを検出できます。
9
+
10
+ 2. **暗号化**: 認証付き暗号(Fernet / AES-128-CBC + HMAC)を使用。`cryptography`
11
+ パッケージが必要です(``pip install SessionSmith[crypto]``)。パスワードから
12
+ PBKDF2-HMAC-SHA256 で鍵を導出します。
13
+
14
+ Note:
15
+ 暗号化はエクスポート/インポートやリモートとの同期など、データが SessionSmith
16
+ の管理下を離れる「境界」での利用を想定しています。pickle ファイルを平文のまま
17
+ 共有・バックアップするリスクを軽減します。
18
+ """
19
+
20
+ import base64
21
+ import hashlib
22
+ import hmac
23
+ import os
24
+ import struct
25
+ from typing import Optional
26
+
27
+ # 暗号化用のオプショナル依存
28
+ # ImportError だけでなく、ネイティブ拡張(cffi/rust)の読み込み失敗など
29
+ # 環境依存の例外も握りつぶし、暗号化機能を無効化して degrade させる。
30
+ try:
31
+ from cryptography.fernet import Fernet, InvalidToken
32
+
33
+ HAS_CRYPTOGRAPHY = True
34
+ except BaseException: # noqa: BLE001 # pragma: no cover - 依存がない/壊れている環境向け
35
+ # ImportError に加え、ネイティブ拡張(cffi/pyo3)の読み込み失敗による
36
+ # PanicException(BaseException 派生)等もここで握りつぶし degrade させる。
37
+ HAS_CRYPTOGRAPHY = False
38
+ Fernet = None # type: ignore
39
+ InvalidToken = Exception # type: ignore
40
+
41
+
42
+ # 暗号化済みデータを識別するためのマジックヘッダー
43
+ MAGIC = b"SSMENC01"
44
+ # 鍵導出のデフォルト反復回数(PBKDF2)
45
+ DEFAULT_ITERATIONS = 200_000
46
+ # ソルト長(バイト)
47
+ SALT_SIZE = 16
48
+
49
+
50
+ class CryptoError(Exception):
51
+ """暗号化・復号・署名検証に関するエラー"""
52
+
53
+
54
+ class CryptoDependencyError(CryptoError):
55
+ """暗号化に必要な依存パッケージが不足している場合のエラー"""
56
+
57
+ def __init__(self) -> None:
58
+ super().__init__(
59
+ "Encryption requires the 'cryptography' package. "
60
+ "Install it with: pip install SessionSmith[crypto]"
61
+ )
62
+
63
+
64
+ # ========== 署名(HMAC-SHA256, 標準ライブラリのみ) ==========
65
+
66
+
67
+ def sign_data(data: bytes, key: str) -> str:
68
+ """
69
+ データに HMAC-SHA256 署名を付与します(改ざん検出用)。
70
+
71
+ Args:
72
+ data: 署名対象のバイト列
73
+ key: 署名鍵(秘密鍵)
74
+
75
+ Returns:
76
+ str: 16進数の署名文字列
77
+ """
78
+ if not isinstance(data, bytes):
79
+ raise TypeError("data must be bytes")
80
+ return hmac.new(key.encode("utf-8"), data, hashlib.sha256).hexdigest()
81
+
82
+
83
+ def verify_signature(data: bytes, signature: str, key: str) -> bool:
84
+ """
85
+ HMAC-SHA256 署名を検証します(タイミング攻撃に耐性のある比較)。
86
+
87
+ Args:
88
+ data: 検証対象のバイト列
89
+ signature: 期待される署名(16進数文字列)
90
+ key: 署名鍵
91
+
92
+ Returns:
93
+ bool: 署名が一致すれば True
94
+ """
95
+ if not signature:
96
+ return False
97
+ expected = sign_data(data, key)
98
+ return hmac.compare_digest(expected, signature)
99
+
100
+
101
+ # ========== 暗号化(Fernet, オプショナル) ==========
102
+
103
+
104
+ def _derive_fernet_key(password: str, salt: bytes, iterations: int = DEFAULT_ITERATIONS) -> bytes:
105
+ """
106
+ パスワードから Fernet 用の鍵(url-safe base64 でエンコードされた32バイト)を導出します。
107
+
108
+ PBKDF2-HMAC-SHA256 を標準ライブラリで実行するため、cryptography の鍵導出機能には
109
+ 依存しません。
110
+ """
111
+ dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations, dklen=32)
112
+ return base64.urlsafe_b64encode(dk)
113
+
114
+
115
+ def is_encrypted(blob: bytes) -> bool:
116
+ """データが SessionSmith により暗号化されたものか判定します。"""
117
+ return isinstance(blob, bytes) and blob[: len(MAGIC)] == MAGIC
118
+
119
+
120
+ def encrypt_data(data: bytes, password: str, iterations: int = DEFAULT_ITERATIONS) -> bytes:
121
+ """
122
+ データを暗号化します(認証付き暗号)。
123
+
124
+ 出力フォーマット::
125
+
126
+ MAGIC(8) | iterations(uint32 BE) | salt(16) | Fernet token(...)
127
+
128
+ Args:
129
+ data: 平文のバイト列
130
+ password: 暗号化パスワード
131
+ iterations: PBKDF2 の反復回数
132
+
133
+ Returns:
134
+ bytes: 暗号化されたバイト列(``is_encrypted`` が True を返す形式)
135
+
136
+ Raises:
137
+ CryptoDependencyError: cryptography パッケージが無い場合
138
+ """
139
+ if not HAS_CRYPTOGRAPHY:
140
+ raise CryptoDependencyError()
141
+ if not password:
142
+ raise CryptoError("Password must not be empty")
143
+ if not isinstance(data, bytes):
144
+ raise TypeError("data must be bytes")
145
+
146
+ salt = os.urandom(SALT_SIZE)
147
+ key = _derive_fernet_key(password, salt, iterations)
148
+ token = Fernet(key).encrypt(data)
149
+ header = MAGIC + struct.pack(">I", iterations) + salt
150
+ return header + token
151
+
152
+
153
+ def decrypt_data(blob: bytes, password: str) -> bytes:
154
+ """
155
+ ``encrypt_data`` で暗号化されたデータを復号します。
156
+
157
+ Args:
158
+ blob: 暗号化されたバイト列
159
+ password: 復号パスワード
160
+
161
+ Returns:
162
+ bytes: 復号された平文
163
+
164
+ Raises:
165
+ CryptoDependencyError: cryptography パッケージが無い場合
166
+ CryptoError: フォーマット不正・パスワード誤り・改ざんを検出した場合
167
+ """
168
+ if not HAS_CRYPTOGRAPHY:
169
+ raise CryptoDependencyError()
170
+ if not is_encrypted(blob):
171
+ raise CryptoError("Data is not in SessionSmith encrypted format")
172
+
173
+ header_len = len(MAGIC) + 4 + SALT_SIZE
174
+ if len(blob) < header_len:
175
+ raise CryptoError("Encrypted data is truncated or corrupted")
176
+
177
+ iterations = struct.unpack(">I", blob[len(MAGIC) : len(MAGIC) + 4])[0]
178
+ salt = blob[len(MAGIC) + 4 : header_len]
179
+ token = blob[header_len:]
180
+
181
+ key = _derive_fernet_key(password, salt, iterations)
182
+ try:
183
+ return Fernet(key).decrypt(token)
184
+ except InvalidToken as e:
185
+ raise CryptoError(
186
+ "Failed to decrypt: wrong password or the data has been tampered with"
187
+ ) from e
188
+
189
+
190
+ def maybe_decrypt(blob: bytes, password: Optional[str]) -> bytes:
191
+ """
192
+ 暗号化されていれば復号し、そうでなければそのまま返します。
193
+
194
+ Args:
195
+ blob: 読み込んだバイト列
196
+ password: パスワード(None で暗号化されていない場合はそのまま)
197
+
198
+ Returns:
199
+ bytes: 平文のバイト列
200
+ """
201
+ if is_encrypted(blob):
202
+ if not password:
203
+ raise CryptoError("Data is encrypted but no password was provided")
204
+ return decrypt_data(blob, password)
205
+ return blob
@@ -118,7 +118,7 @@ def safe_execute(
118
118
  func: Callable[..., T],
119
119
  *args: Any,
120
120
  default: Optional[T] = None,
121
- on_error: Callable[[Exception], Optional[T]] = None,
121
+ on_error: Optional[Callable[[Exception], Optional[T]]] = None,
122
122
  **kwargs: Any
123
123
  ) -> Optional[T]:
124
124
  """
@@ -9,7 +9,8 @@ SessionSmith カスタム例外クラス
9
9
  │ ├── SSMNotInitializedError
10
10
  │ ├── SSMCommitNotFoundError
11
11
  │ ├── SSMNoCommitsError
12
- └── SSMConfigError
12
+ ├── SSMConfigError
13
+ │ └── SSMLockError
13
14
  ├── SessionError (セッション操作関連)
14
15
  │ ├── SessionSaveError
15
16
  │ ├── SessionLoadError
@@ -134,6 +135,40 @@ class SSMRemoteNotFoundError(SSMError):
134
135
  super().__init__(message, details={"remote_name": remote_name})
135
136
 
136
137
 
138
+ class SSMLockError(SSMError):
139
+ """`.ssm` リポジトリのプロセス間ロック取得がタイムアウトした場合の例外"""
140
+
141
+ def __init__(
142
+ self,
143
+ ssm_path: str,
144
+ holder_pid: Optional[int] = None,
145
+ timeout: Optional[float] = None,
146
+ ):
147
+ self.ssm_path = ssm_path
148
+ self.holder_pid = holder_pid
149
+ self.timeout = timeout
150
+
151
+ if holder_pid:
152
+ holder_desc = f"currently held by PID {holder_pid}"
153
+ else:
154
+ holder_desc = "holder could not be determined"
155
+ timeout_desc = f"{timeout:.1f}s" if timeout is not None else "the configured timeout"
156
+
157
+ message = (
158
+ f"Timed out after {timeout_desc} waiting for the lock on '{ssm_path}' "
159
+ f"({holder_desc}). Another process or thread is likely writing to this "
160
+ f".ssm repository. If no other process is actually using it, the lock "
161
+ f"file ('.lock') may be stale from a crashed process; SessionSmith "
162
+ f"automatically reclaims stale locks once the holder process is "
163
+ f"confirmed dead or the lock exceeds its max age, so retrying shortly "
164
+ f"usually resolves this."
165
+ )
166
+ super().__init__(
167
+ message,
168
+ details={"ssm_path": ssm_path, "holder_pid": holder_pid, "timeout": timeout},
169
+ )
170
+
171
+
137
172
  class SSMMergeConflictError(SSMError):
138
173
  """マージコンフリクトが発生した場合の例外"""
139
174
 
@@ -141,7 +176,14 @@ class SSMMergeConflictError(SSMError):
141
176
  self.branch_name = branch_name
142
177
  self.conflicts = conflicts
143
178
  i18n = _get_i18n()
144
- message = i18n.translate("error.merge_conflict", branch_name=branch_name, count=len(conflicts))
179
+ # コンフリクトした変数名をメッセージ本文にも含める(呼び出し側が
180
+ # str(exc) だけを見ても、どの変数が衝突したか分かるようにするため)
181
+ message = i18n.translate(
182
+ "error.merge_conflict",
183
+ branch_name=branch_name,
184
+ count=len(conflicts),
185
+ vars=", ".join(conflicts),
186
+ )
145
187
  super().__init__(message, details={"branch_name": branch_name, "conflicts": conflicts})
146
188
 
147
189
 
@@ -53,7 +53,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
53
53
  "error.remote_no_url": "リモート '{remote_name}' にURLが設定されていません",
54
54
  "error.remote_repository_not_found": "リモートリポジトリが見つかりません: {remote_url}",
55
55
  "error.tag_no_commit": "タグ '{tag_name}' にコミットが設定されていません",
56
- "error.merge_conflict": "マージコンフリクトが発生しました: ブランチ '{branch_name}' で {count} 個のコンフリクト",
56
+ "error.merge_conflict": "マージコンフリクトが発生しました: ブランチ '{branch_name}' で {count} 個のコンフリクト ({vars})",
57
57
 
58
58
  # 警告メッセージ
59
59
  "warn.large_variable": "大きな変数が検出されました: '{name}' ({size_mb:.1f}MB)",
@@ -62,6 +62,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
62
62
  "warn.checkpoint_failed": "チェックポイントの保存に失敗しました: {reason}",
63
63
  "warn.continuous_mode_unavailable": "常時記録モードはJupyter/IPython環境でのみ利用可能です",
64
64
  "warn.variable_conflict": "変数名の衝突が検出されました: ファイル '{previous_file}' と '{current_file}' で同じ変数名 ({vars}) が使用されています。複数のファイルから同じ変数名を使用する場合は注意してください。",
65
+ "warn.merge_conflict": "マージコンフリクトを検出しました: ブランチ '{branch_name}' で変数 {vars} が共通祖先から両側で異なる値に変更されています。マージ呼び出し時点でセッションに存在する値が採用されます(last-writer-wins)。",
65
66
 
66
67
  # 情報メッセージ
67
68
  "info.session_saved": "セッションを保存しました: {file_path} ({size:,} bytes, 形式: {format})",
@@ -122,7 +123,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
122
123
  "error.remote_no_url": "Remote '{remote_name}' has no URL",
123
124
  "error.remote_repository_not_found": "Remote repository not found: {remote_url}",
124
125
  "error.tag_no_commit": "Tag '{tag_name}' has no commit",
125
- "error.merge_conflict": "Merge conflict: {count} conflicts in branch '{branch_name}'",
126
+ "error.merge_conflict": "Merge conflict: {count} conflicts in branch '{branch_name}' ({vars})",
126
127
 
127
128
  # Warning messages
128
129
  "warn.large_variable": "Large variable detected: '{name}' ({size_mb:.1f}MB)",
@@ -131,6 +132,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
131
132
  "warn.checkpoint_failed": "Checkpoint save failed: {reason}",
132
133
  "warn.continuous_mode_unavailable": "Continuous mode is only available in Jupyter/IPython environment",
133
134
  "warn.variable_conflict": "Variable name conflict detected: same variable names ({vars}) used in '{previous_file}' and '{current_file}'. Be careful when using the same variable names from multiple files.",
135
+ "warn.merge_conflict": "Merge conflict detected: variables {vars} were changed differently on both sides since the common ancestor in branch '{branch_name}'. The value currently in the session at merge time will be used (last-writer-wins).",
134
136
  "warn.disk_warning": "Disk space warning: {usage_percent:.1f}% used ({free_mb:.1f}MB free)",
135
137
  "warn.disk_critical": "Disk space critical: {usage_percent:.1f}% used ({free_mb:.1f}MB free)",
136
138
  "warn.memory_warning": "Memory usage warning: {usage_percent:.1f}% used ({used_mb:.1f}MB used, {available_mb:.1f}MB available)",
@@ -210,7 +212,7 @@ def get_language() -> str:
210
212
  if _current_language == Language.AUTO:
211
213
  return detect_language()
212
214
  else:
213
- return _current_language.value
215
+ return str(_current_language.value)
214
216
 
215
217
 
216
218
  def set_language(lang: Union[str, Language], save_to_ssm: bool = True) -> None:
@@ -31,7 +31,6 @@ def _load_session_file(file_path: Union[str, Path], format: Optional[str] = None
31
31
  if not file_path.is_file():
32
32
  raise ValueError(f"'{file_path}' is not a file.")
33
33
 
34
- compression: Optional[str] = None
35
34
  from .formats import detect_format, load_hdf5, load_json, load_msgpack, load_pickle
36
35
 
37
36
  session: dict[str, Any] = {}