code-meter 0.1.0__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.
- code_meter/__init__.py +3 -0
- code_meter/analytics/__init__.py +7 -0
- code_meter/analytics/costs.py +43 -0
- code_meter/analytics/reports.py +357 -0
- code_meter/analytics/tokens.py +51 -0
- code_meter/cli.py +334 -0
- code_meter/config.py +116 -0
- code_meter/models/__init__.py +13 -0
- code_meter/models/project.py +85 -0
- code_meter/models/usage.py +61 -0
- code_meter/pricing/__init__.py +14 -0
- code_meter/pricing/anthropic.py +78 -0
- code_meter/pricing/engine.py +97 -0
- code_meter/pricing/google.py +60 -0
- code_meter/pricing/openai.py +69 -0
- code_meter/providers/__init__.py +15 -0
- code_meter/providers/antigravity.py +358 -0
- code_meter/providers/base.py +42 -0
- code_meter/providers/claude_code.py +378 -0
- code_meter/providers/codex.py +356 -0
- code_meter/storage/__init__.py +6 -0
- code_meter/storage/database.py +147 -0
- code_meter/storage/repository.py +482 -0
- code_meter/ui/__init__.py +19 -0
- code_meter/ui/dashboard.py +111 -0
- code_meter/ui/tables.py +195 -0
- code_meter/watcher.py +56 -0
- code_meter-0.1.0.dist-info/METADATA +207 -0
- code_meter-0.1.0.dist-info/RECORD +33 -0
- code_meter-0.1.0.dist-info/WHEEL +5 -0
- code_meter-0.1.0.dist-info/entry_points.txt +6 -0
- code_meter-0.1.0.dist-info/licenses/LICENSE +21 -0
- code_meter-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Base UsageProvider interface for extensible multi-provider AI usage tracking."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from code_meter.models.usage import SessionCodeChange, SessionPrompt, UsageRecord
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FileScanState(BaseModel):
|
|
11
|
+
"""Incremental scan state per file."""
|
|
12
|
+
|
|
13
|
+
file_path: str
|
|
14
|
+
file_size: int
|
|
15
|
+
modified_time: float
|
|
16
|
+
read_offset: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ScanResult(BaseModel):
|
|
20
|
+
"""Result of provider scan containing new records, prompts, code changes, and updated file states."""
|
|
21
|
+
|
|
22
|
+
records: List[UsageRecord] = Field(default_factory=list)
|
|
23
|
+
prompts: List[SessionPrompt] = Field(default_factory=list)
|
|
24
|
+
code_changes: List[SessionCodeChange] = Field(default_factory=list)
|
|
25
|
+
updated_states: List[FileScanState] = Field(default_factory=list)
|
|
26
|
+
files_scanned: int = 0
|
|
27
|
+
malformed_lines: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UsageProvider(ABC):
|
|
31
|
+
"""Abstract interface for all AI coding agent usage providers."""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def provider_name(self) -> str:
|
|
36
|
+
"""Name identifier of provider (e.g., 'claude_code', 'codex', 'gemini')."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def scan(self, existing_states: Optional[Dict[str, FileScanState]] = None) -> ScanResult:
|
|
41
|
+
"""Scan provider local storage and return normalized records, prompts, code changes, and state updates."""
|
|
42
|
+
pass
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Claude Code provider for scanning local ~/.claude JSONL session files."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from code_meter.models.usage import SessionCodeChange, SessionPrompt, UsageRecord
|
|
12
|
+
from code_meter.providers.base import FileScanState, ScanResult, UsageProvider
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClaudeCodeProvider(UsageProvider):
|
|
18
|
+
"""Scanner for Claude Code local JSONL session logs."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, claude_dir: Path):
|
|
21
|
+
self.claude_dir = Path(os.path.expanduser(claude_dir)).resolve()
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def provider_name(self) -> str:
|
|
25
|
+
return "claude_code"
|
|
26
|
+
|
|
27
|
+
def scan(self, existing_states: Optional[Dict[str, FileScanState]] = None) -> ScanResult:
|
|
28
|
+
"""Incrementally scan all *.jsonl files in Claude Code directory."""
|
|
29
|
+
existing = existing_states or {}
|
|
30
|
+
records: List[UsageRecord] = []
|
|
31
|
+
prompts: List[SessionPrompt] = []
|
|
32
|
+
code_changes: List[SessionCodeChange] = []
|
|
33
|
+
updated_states: List[FileScanState] = []
|
|
34
|
+
files_scanned = 0
|
|
35
|
+
malformed_lines = 0
|
|
36
|
+
|
|
37
|
+
if not self.claude_dir.exists():
|
|
38
|
+
logger.warning(f"Claude directory does not exist: {self.claude_dir}")
|
|
39
|
+
return ScanResult(records=[], prompts=[], code_changes=[], updated_states=[], files_scanned=0, malformed_lines=0)
|
|
40
|
+
|
|
41
|
+
jsonl_files = self._find_jsonl_files()
|
|
42
|
+
|
|
43
|
+
for file_path in jsonl_files:
|
|
44
|
+
try:
|
|
45
|
+
stat = file_path.stat()
|
|
46
|
+
file_str = str(file_path.resolve())
|
|
47
|
+
size = stat.st_size
|
|
48
|
+
mtime = stat.st_mtime
|
|
49
|
+
|
|
50
|
+
prev_state = existing.get(file_str)
|
|
51
|
+
|
|
52
|
+
if prev_state:
|
|
53
|
+
if size == prev_state.file_size and mtime == prev_state.modified_time:
|
|
54
|
+
updated_states.append(prev_state)
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
if size < prev_state.file_size:
|
|
58
|
+
start_offset = 0
|
|
59
|
+
else:
|
|
60
|
+
start_offset = prev_state.read_offset
|
|
61
|
+
else:
|
|
62
|
+
start_offset = 0
|
|
63
|
+
|
|
64
|
+
files_scanned += 1
|
|
65
|
+
new_records, new_prompts, new_changes, new_offset, malformed = self._scan_file(
|
|
66
|
+
file_path=file_path,
|
|
67
|
+
start_offset=start_offset,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
records.extend(new_records)
|
|
71
|
+
prompts.extend(new_prompts)
|
|
72
|
+
code_changes.extend(new_changes)
|
|
73
|
+
malformed_lines += malformed
|
|
74
|
+
|
|
75
|
+
updated_states.append(
|
|
76
|
+
FileScanState(
|
|
77
|
+
file_path=file_str,
|
|
78
|
+
file_size=size,
|
|
79
|
+
modified_time=mtime,
|
|
80
|
+
read_offset=new_offset,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(f"Error scanning file {file_path}: {e}", exc_info=True)
|
|
86
|
+
|
|
87
|
+
return ScanResult(
|
|
88
|
+
records=records,
|
|
89
|
+
prompts=prompts,
|
|
90
|
+
code_changes=code_changes,
|
|
91
|
+
updated_states=updated_states,
|
|
92
|
+
files_scanned=files_scanned,
|
|
93
|
+
malformed_lines=malformed_lines,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _find_jsonl_files(self) -> List[Path]:
|
|
97
|
+
"""Find all jsonl session files recursively under self.claude_dir."""
|
|
98
|
+
files: List[Path] = []
|
|
99
|
+
try:
|
|
100
|
+
for root, _, filenames in os.walk(self.claude_dir):
|
|
101
|
+
for fname in filenames:
|
|
102
|
+
if fname.endswith(".jsonl") and fname != "history.jsonl":
|
|
103
|
+
files.append(Path(root) / fname)
|
|
104
|
+
except PermissionError as e:
|
|
105
|
+
logger.warning(f"Permission denied accessing {self.claude_dir}: {e}")
|
|
106
|
+
return files
|
|
107
|
+
|
|
108
|
+
def _scan_file(
|
|
109
|
+
self, file_path: Path, start_offset: int
|
|
110
|
+
) -> tuple[List[UsageRecord], List[SessionPrompt], List[SessionCodeChange], int, int]:
|
|
111
|
+
"""Scan a single JSONL file from start_offset to EOF."""
|
|
112
|
+
records: List[UsageRecord] = []
|
|
113
|
+
prompts: List[SessionPrompt] = []
|
|
114
|
+
changes: List[SessionCodeChange] = []
|
|
115
|
+
malformed = 0
|
|
116
|
+
current_offset = start_offset
|
|
117
|
+
|
|
118
|
+
session_id_from_path = file_path.stem if file_path.name != "history.jsonl" else None
|
|
119
|
+
project_path_from_path = self._infer_project_path(file_path)
|
|
120
|
+
project_name = self._project_name_from_path(project_path_from_path) if project_path_from_path else None
|
|
121
|
+
|
|
122
|
+
line_index = 0
|
|
123
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
124
|
+
if start_offset > 0:
|
|
125
|
+
f.seek(start_offset)
|
|
126
|
+
|
|
127
|
+
while True:
|
|
128
|
+
line = f.readline()
|
|
129
|
+
if not line:
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
current_offset = f.tell()
|
|
133
|
+
line_index += 1
|
|
134
|
+
line_str = line.strip()
|
|
135
|
+
if not line_str:
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
data = json.loads(line_str)
|
|
140
|
+
except Exception:
|
|
141
|
+
malformed += 1
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
session_id = data.get("sessionId") or session_id_from_path or "unknown_session"
|
|
145
|
+
cwd = data.get("cwd") or project_path_from_path
|
|
146
|
+
p_name = self._project_name_from_path(cwd) if cwd else project_name
|
|
147
|
+
|
|
148
|
+
# 1. Parse assistant usage
|
|
149
|
+
rec = self._parse_usage_record(data, str(file_path.resolve()), session_id, cwd, p_name)
|
|
150
|
+
if rec:
|
|
151
|
+
records.append(rec)
|
|
152
|
+
|
|
153
|
+
# 2. Parse user prompt
|
|
154
|
+
prompt = self._parse_user_prompt(data, session_id, p_name, line_index)
|
|
155
|
+
if prompt:
|
|
156
|
+
prompts.append(prompt)
|
|
157
|
+
|
|
158
|
+
# 3. Parse code changes / file snapshots / tool edits
|
|
159
|
+
file_changes = self._parse_code_changes(data, session_id, p_name, line_index)
|
|
160
|
+
if file_changes:
|
|
161
|
+
changes.extend(file_changes)
|
|
162
|
+
|
|
163
|
+
return records, prompts, changes, current_offset, malformed
|
|
164
|
+
|
|
165
|
+
def _parse_usage_record(
|
|
166
|
+
self,
|
|
167
|
+
data: dict,
|
|
168
|
+
file_path: str,
|
|
169
|
+
session_id: str,
|
|
170
|
+
cwd: Optional[str],
|
|
171
|
+
project_name: Optional[str],
|
|
172
|
+
) -> Optional[UsageRecord]:
|
|
173
|
+
if not isinstance(data, dict):
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
msg = data.get("message")
|
|
177
|
+
if not isinstance(msg, dict):
|
|
178
|
+
msg = {}
|
|
179
|
+
|
|
180
|
+
usage = msg.get("usage")
|
|
181
|
+
if not isinstance(usage, dict):
|
|
182
|
+
usage = data.get("usage")
|
|
183
|
+
if not isinstance(usage, dict):
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
input_tokens = int(usage.get("input_tokens", 0) or 0)
|
|
187
|
+
output_tokens = int(usage.get("output_tokens", 0) or 0)
|
|
188
|
+
cache_read_tokens = int(usage.get("cache_read_input_tokens", 0) or 0)
|
|
189
|
+
cache_write_tokens = int(usage.get("cache_creation_input_tokens", 0) or 0)
|
|
190
|
+
|
|
191
|
+
if (input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) == 0:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
model = msg.get("model") or data.get("model") or "unknown"
|
|
195
|
+
if not isinstance(model, str):
|
|
196
|
+
model = str(model)
|
|
197
|
+
|
|
198
|
+
raw_ts = data.get("timestamp") or msg.get("timestamp")
|
|
199
|
+
ts = self._parse_timestamp(raw_ts)
|
|
200
|
+
|
|
201
|
+
request_id = msg.get("id") or data.get("uuid") or data.get("id")
|
|
202
|
+
if not request_id or not isinstance(request_id, str):
|
|
203
|
+
request_id = self._generate_deterministic_id(
|
|
204
|
+
file_path=file_path,
|
|
205
|
+
timestamp=ts,
|
|
206
|
+
model=model,
|
|
207
|
+
input_tokens=input_tokens,
|
|
208
|
+
output_tokens=output_tokens,
|
|
209
|
+
cache_read_tokens=cache_read_tokens,
|
|
210
|
+
cache_write_tokens=cache_write_tokens,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return UsageRecord(
|
|
214
|
+
provider="claude_code",
|
|
215
|
+
request_id=request_id,
|
|
216
|
+
session_id=session_id,
|
|
217
|
+
timestamp=ts,
|
|
218
|
+
user_id=data.get("user_id"),
|
|
219
|
+
project_id=project_name,
|
|
220
|
+
project_path=cwd,
|
|
221
|
+
model=model,
|
|
222
|
+
input_tokens=input_tokens,
|
|
223
|
+
output_tokens=output_tokens,
|
|
224
|
+
cache_read_tokens=cache_read_tokens,
|
|
225
|
+
cache_write_tokens=cache_write_tokens,
|
|
226
|
+
metadata={"file_path": file_path, "type": data.get("type")},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def _parse_user_prompt(
|
|
230
|
+
self, data: dict, session_id: str, project_id: Optional[str], line_index: int
|
|
231
|
+
) -> Optional[SessionPrompt]:
|
|
232
|
+
"""Extract user prompt text if line represents a user message."""
|
|
233
|
+
line_type = data.get("type")
|
|
234
|
+
msg = data.get("message") if isinstance(data.get("message"), dict) else {}
|
|
235
|
+
role = msg.get("role") or data.get("role")
|
|
236
|
+
|
|
237
|
+
if line_type != "user" and role != "user":
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
content = msg.get("content") or data.get("content")
|
|
241
|
+
prompt_text = ""
|
|
242
|
+
|
|
243
|
+
if isinstance(content, str):
|
|
244
|
+
prompt_text = content.strip()
|
|
245
|
+
elif isinstance(content, list):
|
|
246
|
+
texts = []
|
|
247
|
+
for block in content:
|
|
248
|
+
if isinstance(block, str):
|
|
249
|
+
texts.append(block)
|
|
250
|
+
elif isinstance(block, dict) and block.get("type") == "text":
|
|
251
|
+
texts.append(block.get("text", ""))
|
|
252
|
+
prompt_text = "\n".join(texts).strip()
|
|
253
|
+
|
|
254
|
+
if not prompt_text or prompt_text.startswith("<local-command-caveat>"):
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
raw_ts = data.get("timestamp") or msg.get("timestamp")
|
|
258
|
+
ts = self._parse_timestamp(raw_ts)
|
|
259
|
+
|
|
260
|
+
prompt_id = data.get("promptId") or data.get("uuid") or f"prompt_{session_id}_{line_index}"
|
|
261
|
+
|
|
262
|
+
return SessionPrompt(
|
|
263
|
+
provider="claude_code",
|
|
264
|
+
session_id=session_id,
|
|
265
|
+
prompt_id=str(prompt_id),
|
|
266
|
+
timestamp=ts,
|
|
267
|
+
prompt_text=prompt_text,
|
|
268
|
+
project_id=project_id,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def _parse_code_changes(
|
|
272
|
+
self, data: dict, session_id: str, project_id: Optional[str], line_index: int
|
|
273
|
+
) -> List[SessionCodeChange]:
|
|
274
|
+
"""Extract code changes, file history snapshots, or edit tool calls."""
|
|
275
|
+
changes: List[SessionCodeChange] = []
|
|
276
|
+
raw_ts = data.get("timestamp")
|
|
277
|
+
ts = self._parse_timestamp(raw_ts)
|
|
278
|
+
|
|
279
|
+
# 1. file-history-snapshot
|
|
280
|
+
if data.get("type") == "file-history-snapshot":
|
|
281
|
+
snapshot = data.get("snapshot", {})
|
|
282
|
+
tracked = snapshot.get("trackedFileBackups", {})
|
|
283
|
+
if isinstance(tracked, dict):
|
|
284
|
+
for fpath, backup in tracked.items():
|
|
285
|
+
change_id = f"snap_{session_id}_{hashlib.md5(fpath.encode()).hexdigest()[:8]}_{line_index}"
|
|
286
|
+
summary = f"File snapshot backup: {backup.get('backupFileName', 'modified')}" if isinstance(backup, dict) else "File snapshot update"
|
|
287
|
+
changes.append(
|
|
288
|
+
SessionCodeChange(
|
|
289
|
+
provider="claude_code",
|
|
290
|
+
session_id=session_id,
|
|
291
|
+
change_id=change_id,
|
|
292
|
+
timestamp=ts,
|
|
293
|
+
file_path=str(fpath),
|
|
294
|
+
change_type="snapshot",
|
|
295
|
+
diff_summary=summary,
|
|
296
|
+
project_id=project_id,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# 2. Assistant tool call edits (Edit, Write, Replace, etc.)
|
|
301
|
+
msg = data.get("message")
|
|
302
|
+
if isinstance(msg, dict) and isinstance(msg.get("content"), list):
|
|
303
|
+
for idx, block in enumerate(msg.get("content", [])):
|
|
304
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
305
|
+
tname = block.get("name", "")
|
|
306
|
+
tinput = block.get("input", {})
|
|
307
|
+
if isinstance(tinput, dict) and tname in ["Edit", "Write", "Replace", "StrReplace", "NotebookEditCell", "WriteFile", "EditFile"]:
|
|
308
|
+
fpath = tinput.get("file_path") or tinput.get("path") or tinput.get("target_file") or "unknown_file"
|
|
309
|
+
old_str = tinput.get("old_string") or tinput.get("TargetContent") or ""
|
|
310
|
+
new_str = tinput.get("new_string") or tinput.get("ReplacementContent") or tinput.get("content") or ""
|
|
311
|
+
|
|
312
|
+
diff_lines = []
|
|
313
|
+
if old_str:
|
|
314
|
+
diff_lines.append(f"- {old_str[:150]}")
|
|
315
|
+
if new_str:
|
|
316
|
+
diff_lines.append(f"+ {new_str[:150]}")
|
|
317
|
+
diff_summary = "\n".join(diff_lines) if diff_lines else f"Tool action: {tname}"
|
|
318
|
+
|
|
319
|
+
change_id = block.get("id") or f"edit_{session_id}_{line_index}_{idx}"
|
|
320
|
+
changes.append(
|
|
321
|
+
SessionCodeChange(
|
|
322
|
+
provider="claude_code",
|
|
323
|
+
session_id=session_id,
|
|
324
|
+
change_id=str(change_id),
|
|
325
|
+
timestamp=ts,
|
|
326
|
+
file_path=str(fpath),
|
|
327
|
+
change_type=tname.lower(),
|
|
328
|
+
diff_summary=diff_summary,
|
|
329
|
+
project_id=project_id,
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return changes
|
|
334
|
+
|
|
335
|
+
def _parse_timestamp(self, raw_ts: Optional[str]) -> datetime:
|
|
336
|
+
if not raw_ts:
|
|
337
|
+
return datetime.now(timezone.utc)
|
|
338
|
+
if isinstance(raw_ts, (int, float)):
|
|
339
|
+
return datetime.fromtimestamp(raw_ts / 1000.0, tz=timezone.utc)
|
|
340
|
+
try:
|
|
341
|
+
ts_str = str(raw_ts).replace("Z", "+00:00")
|
|
342
|
+
return datetime.fromisoformat(ts_str)
|
|
343
|
+
except ValueError:
|
|
344
|
+
return datetime.now(timezone.utc)
|
|
345
|
+
|
|
346
|
+
def _generate_deterministic_id(
|
|
347
|
+
self,
|
|
348
|
+
file_path: str,
|
|
349
|
+
timestamp: datetime,
|
|
350
|
+
model: str,
|
|
351
|
+
input_tokens: int,
|
|
352
|
+
output_tokens: int,
|
|
353
|
+
cache_read_tokens: int,
|
|
354
|
+
cache_write_tokens: int,
|
|
355
|
+
) -> str:
|
|
356
|
+
seed = f"claude_code:{file_path}:{timestamp.isoformat()}:{model}:{input_tokens}:{output_tokens}:{cache_read_tokens}:{cache_write_tokens}"
|
|
357
|
+
return "gen_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
|
|
358
|
+
|
|
359
|
+
def _infer_project_path(self, file_path: Path) -> Optional[str]:
|
|
360
|
+
try:
|
|
361
|
+
parent_dir = file_path.parent.name
|
|
362
|
+
if parent_dir.startswith("C--") or parent_dir.startswith("-"):
|
|
363
|
+
parts = parent_dir.split("--")
|
|
364
|
+
if len(parts) >= 2:
|
|
365
|
+
drive = parts[0]
|
|
366
|
+
rest = parts[1].replace("-", "/")
|
|
367
|
+
return f"{drive}:/{rest}"
|
|
368
|
+
else:
|
|
369
|
+
return parent_dir.replace("-", "/")
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
def _project_name_from_path(self, path_str: Optional[str]) -> Optional[str]:
|
|
375
|
+
if not path_str:
|
|
376
|
+
return None
|
|
377
|
+
clean = path_str.rstrip("/\\")
|
|
378
|
+
return os.path.basename(clean) or clean
|