Skip to content

Commit 58aabd6

Browse files
authored
Merge pull request #25 from patchmemory/task/combined-folder-config-and-logs-pr
feat: folder-config precedence + logs endpoint + ipynb streaming
2 parents d245210 + 472233d commit 58aabd6

File tree

4 files changed

+200
-14
lines changed

4 files changed

+200
-14
lines changed

scidk/app.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5001,6 +5001,80 @@ def api_metrics():
50015001
except Exception as e:
50025002
return jsonify({'error': str(e)}), 500
50035003

5004+
@api.get('/logs')
5005+
def api_logs():
5006+
"""
5007+
Browse operational logs with pagination and filters.
5008+
Query params: limit, offset, level, since_ts
5009+
Privacy: No sensitive file paths or user data exposed.
5010+
"""
5011+
try:
5012+
from .core import path_index_sqlite as pix
5013+
limit = min(int(request.args.get('limit', 100)), 1000)
5014+
offset = int(request.args.get('offset', 0))
5015+
level = request.args.get('level', '').strip().upper() or None
5016+
since_ts = request.args.get('since_ts', '').strip() or None
5017+
5018+
conn = pix.connect()
5019+
try:
5020+
cur = conn.cursor()
5021+
# Build query with filters
5022+
conditions = []
5023+
params = []
5024+
if level:
5025+
conditions.append("level = ?")
5026+
params.append(level)
5027+
if since_ts:
5028+
try:
5029+
ts_val = float(since_ts)
5030+
conditions.append("ts >= ?")
5031+
params.append(ts_val)
5032+
except Exception:
5033+
pass
5034+
5035+
where_clause = ""
5036+
if conditions:
5037+
where_clause = " WHERE " + " AND ".join(conditions)
5038+
5039+
# Get total count
5040+
count_query = f"SELECT COUNT(*) FROM logs{where_clause}"
5041+
cur.execute(count_query, params)
5042+
row = cur.fetchone()
5043+
total = row[0] if row else 0
5044+
5045+
# Get logs (most recent first)
5046+
query = f"""
5047+
SELECT ts, level, message, context
5048+
FROM logs{where_clause}
5049+
ORDER BY ts DESC
5050+
LIMIT ? OFFSET ?
5051+
"""
5052+
cur.execute(query, params + [limit, offset])
5053+
rows = cur.fetchall()
5054+
5055+
logs = []
5056+
for row in rows:
5057+
logs.append({
5058+
'ts': row[0],
5059+
'level': row[1],
5060+
'message': row[2],
5061+
'context': row[3]
5062+
})
5063+
5064+
return jsonify({
5065+
'logs': logs,
5066+
'total': total,
5067+
'limit': limit,
5068+
'offset': offset
5069+
}), 200
5070+
finally:
5071+
try:
5072+
conn.close()
5073+
except Exception:
5074+
pass
5075+
except Exception as e:
5076+
return jsonify({'error': str(e)}), 500
5077+
50045078
# Rclone interpretation settings (GET/POST)
50055079
@api.get('/settings/rclone-interpret')
50065080
def api_settings_rclone_interpret_get():

scidk/core/folder_config.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
from pathlib import Path
33
from typing import Dict, Any, Optional
44

5-
import tomllib # Python 3.11+ (built-in)
5+
# Python 3.11+ has tomllib built-in, 3.10 needs tomli backport
6+
try:
7+
import tomllib
8+
except ModuleNotFoundError:
9+
import tomli as tomllib # type: ignore
610

711
DEFAULTS = {
812
'include': [], # list of glob patterns

scidk/services/scans_service.py

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -226,20 +226,9 @@ def _dir_signature(dir_path: Path):
226226
key = str(dpath.resolve())
227227
conf = _conf_cache.get(key)
228228
if conf is None:
229-
# Prefer local .scidk.toml in this directory (closest wins), then fall back to effective config
229+
# Use load_effective_config to properly honor per-folder precedence
230230
try:
231-
tpath = Path(dpath) / '.scidk.toml'
232-
if tpath.exists():
233-
import tomllib as _toml
234-
try:
235-
data = _toml.loads(tpath.read_text(encoding='utf-8'))
236-
except Exception:
237-
data = {}
238-
inc = data.get('include') if isinstance(data.get('include'), list) else []
239-
exc = data.get('exclude') if isinstance(data.get('exclude'), list) else []
240-
conf = {'include': [str(x) for x in inc], 'exclude': [str(x) for x in exc], 'interpreters': None}
241-
else:
242-
conf = load_effective_config(dpath, stop_at=base)
231+
conf = load_effective_config(dpath, stop_at=base)
243232
except Exception:
244233
conf = {'include': [], 'exclude': [], 'interpreters': None}
245234
_conf_cache[key] = conf

tests/test_logs_endpoint.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import time
2+
from scidk.app import create_app
3+
4+
5+
def test_logs_endpoint_exists():
6+
"""Test that /api/logs endpoint exists and returns expected structure."""
7+
app = create_app()
8+
app.config['TESTING'] = True
9+
with app.test_client() as c:
10+
r = c.get('/api/logs')
11+
assert r.status_code == 200
12+
data = r.get_json()
13+
# Expect keys present
14+
assert 'logs' in data
15+
assert 'total' in data
16+
assert 'limit' in data
17+
assert 'offset' in data
18+
assert isinstance(data['logs'], list)
19+
20+
21+
def test_logs_endpoint_pagination():
22+
"""Test that pagination parameters work correctly."""
23+
app = create_app()
24+
app.config['TESTING'] = True
25+
with app.test_client() as c:
26+
# Test with custom limit and offset
27+
r = c.get('/api/logs?limit=5&offset=0')
28+
assert r.status_code == 200
29+
data = r.get_json()
30+
assert data['limit'] == 5
31+
assert data['offset'] == 0
32+
assert len(data['logs']) <= 5
33+
34+
35+
def test_logs_endpoint_level_filter():
36+
"""Test that level filter works correctly."""
37+
app = create_app()
38+
app.config['TESTING'] = True
39+
with app.test_client() as c:
40+
# Insert a test log entry
41+
from scidk.core import path_index_sqlite as pix
42+
conn = pix.connect()
43+
try:
44+
from scidk.core import migrations as _migs
45+
_migs.migrate(conn)
46+
cur = conn.cursor()
47+
cur.execute(
48+
"INSERT INTO logs (ts, level, message, context) VALUES (?, ?, ?, ?)",
49+
(time.time(), 'ERROR', 'Test error message', None)
50+
)
51+
conn.commit()
52+
finally:
53+
try:
54+
conn.close()
55+
except Exception:
56+
pass
57+
58+
# Query with level filter
59+
r = c.get('/api/logs?level=ERROR')
60+
assert r.status_code == 200
61+
data = r.get_json()
62+
# Should have at least our test error
63+
error_logs = [log for log in data['logs'] if log['level'] == 'ERROR']
64+
assert len(error_logs) > 0
65+
66+
67+
def test_logs_endpoint_since_ts_filter():
68+
"""Test that since_ts filter works correctly."""
69+
app = create_app()
70+
app.config['TESTING'] = True
71+
with app.test_client() as c:
72+
# Insert test log entries with different timestamps
73+
from scidk.core import path_index_sqlite as pix
74+
conn = pix.connect()
75+
try:
76+
from scidk.core import migrations as _migs
77+
_migs.migrate(conn)
78+
cur = conn.cursor()
79+
now = time.time()
80+
cur.execute(
81+
"INSERT INTO logs (ts, level, message, context) VALUES (?, ?, ?, ?)",
82+
(now - 100, 'INFO', 'Old log', None)
83+
)
84+
cur.execute(
85+
"INSERT INTO logs (ts, level, message, context) VALUES (?, ?, ?, ?)",
86+
(now, 'INFO', 'Recent log', None)
87+
)
88+
conn.commit()
89+
finally:
90+
try:
91+
conn.close()
92+
except Exception:
93+
pass
94+
95+
# Query with since_ts filter (only recent logs)
96+
cutoff = time.time() - 50
97+
r = c.get(f'/api/logs?since_ts={cutoff}')
98+
assert r.status_code == 200
99+
data = r.get_json()
100+
# All returned logs should be after cutoff
101+
for log in data['logs']:
102+
assert log['ts'] >= cutoff
103+
104+
105+
def test_logs_endpoint_no_sensitive_data():
106+
"""Test that logs don't expose sensitive file paths or user data."""
107+
app = create_app()
108+
app.config['TESTING'] = True
109+
with app.test_client() as c:
110+
r = c.get('/api/logs')
111+
assert r.status_code == 200
112+
data = r.get_json()
113+
# Verify response structure contains only safe fields
114+
for log in data['logs']:
115+
assert set(log.keys()) == {'ts', 'level', 'message', 'context'}
116+
# No 'user', 'password', 'secret', etc. fields
117+
assert 'user' not in log
118+
assert 'password' not in log
119+
assert 'secret' not in log

0 commit comments

Comments
 (0)