Skip to content

Commit 13f6a12

Browse files
refactor: decouple export and slackbot from file I/O
- Add prepare_events() for in-memory dedupe, sort, and date formatting - Add fmt_events() to slackbot for direct list[dict] formatting - Add optional df param to export_to_file to skip format_response - Refactor get_events/post_slack endpoints to use in-memory data flow - Refactor fmt_json to delegate to fmt_events Closes TASK-014 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8e676a1 commit 13f6a12

5 files changed

Lines changed: 211 additions & 54 deletions

File tree

app/main.py

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -412,28 +412,24 @@ def get_events(
412412
exclusion_list = exclusion_list + exclusions
413413

414414
response = send_request(access_token, query, vars)
415-
416-
export_to_file(response, format, exclusions=exclusion_list)
415+
frames = [format_response(response, exclusions=exclusion_list)]
417416

418417
# third-party query (batched)
419418
responses = send_batched_group_request(access_token, url_vars)
420-
output = []
421419
for i, response in enumerate(responses):
422-
if len(format_response(response, exclusions=exclusion_list)) > 0:
423-
output.append(response)
420+
df = format_response(response, exclusions=exclusion_list)
421+
if len(df) > 0:
422+
frames.append(df)
424423
else:
425424
print(f"{Fore.GREEN}{info:<10}{Fore.RESET}No upcoming events for {url_vars[i]} found")
426-
for resp in output:
427-
export_to_file(resp, format)
428425

429-
# cleanup output file
430-
sort_json(json_fn)
426+
combined = pd.concat(frames, ignore_index=True)
427+
events = prepare_events(combined)
431428

432-
# check if file exists after sorting
433-
if not os.path.exists(json_fn) or os.stat(json_fn).st_size == 0:
429+
if not events:
434430
return {"message": "No events found", "events": []}
435431

436-
return pd.read_json(json_fn).to_dict('records')
432+
return events
437433

438434

439435
@api_router.get("/check-schedule")
@@ -493,19 +489,18 @@ def post_slack(
493489

494490
check_auth(auth)
495491

496-
get_events(auth=auth, location=location, exclusions=exclusions)
492+
events = get_events(auth=auth, location=location, exclusions=exclusions)
493+
494+
# handle "no events found" response
495+
if isinstance(events, dict):
496+
events = events.get("events", [])
497497

498-
# open json file and convert to list of strings
499-
msg = fmt_json(json_fn)
498+
msg = fmt_events(events)
500499

501-
# if channel_name is not None, post to channel as one concatenated string
502500
if channel_name is not None:
503-
# get channel id chan_dict key value pair
504501
channel_id = chan_dict[channel_name]
505-
# post to single channel
506502
send_message("\n".join(msg), channel_id)
507503
else:
508-
# post to all channels
509504
for name, id in channels.items():
510505
send_message("\n".join(msg), id)
511506

app/meetup_query.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,11 +428,53 @@ def sort_json(filename) -> None:
428428
json.dump(data, f, indent=2)
429429

430430

431-
def export_to_file(response, type: str = 'json', exclusions: str = '') -> None:
431+
def prepare_events(df) -> list[dict]:
432+
"""Deduplicate, sort, filter past events, and format dates on a DataFrame. Returns list of dicts."""
433+
if df.empty:
434+
return []
435+
436+
df = df.drop_duplicates(subset='eventUrl').copy()
437+
438+
dates = df['date'].to_dict()
439+
for key, value in dates.items():
440+
if isinstance(value, pd.Timestamp):
441+
dates[key] = value.strftime('%Y-%m-%dT%H:%M:%S')
442+
elif isinstance(value, str):
443+
try:
444+
parsed = arrow.get(value, 'ddd M/D h:mm a')
445+
if parsed.year == 1:
446+
parsed = parsed.replace(year=arrow.now(tz).year)
447+
dates[key] = parsed.format('YYYY-MM-DDTHH:mm:ss')
448+
except ParserError:
449+
try:
450+
dates[key] = arrow.get(value).format('YYYY-MM-DDTHH:mm:ss')
451+
except ParserError:
452+
print(f"{Fore.RED}{error:<10}{Fore.RESET}Unparseable date: {value!r}")
453+
dates[key] = None
454+
else:
455+
print(f"{Fore.YELLOW}{warning:<10}{Fore.RESET}Unexpected date type {type(value).__name__}: {value!r}")
456+
dates[key] = None
457+
df['date'] = pd.Series(dates)
458+
459+
df['date'] = pd.to_datetime(df['date'], format='%Y-%m-%dT%H:%M:%S', errors='coerce')
460+
df['date'] = df['date'].dt.tz_localize(None)
461+
df['date'] = df['date'].apply(lambda x: x.replace(year=1970, month=1, day=1) if pd.isnull(x) else x)
462+
463+
df = df.sort_values(by=['date'])
464+
df = df[df['date'] >= arrow.now(tz).format('YYYY-MM-DDTHH:mm:ss')]
465+
df = df.reset_index(drop=True)
466+
467+
df['date'] = df['date'].apply(lambda x: arrow.get(x).format('ddd M/D h:mm a'))
468+
469+
return json.loads(df.to_json(orient='records', force_ascii=False))
470+
471+
472+
def export_to_file(response, type: str = 'json', exclusions: str = '', df=None) -> None:
432473
"""
433474
Export to CSV or JSON
434475
"""
435-
df = format_response(response, exclusions=exclusions) if exclusions != '' else format_response(response)
476+
if df is None:
477+
df = format_response(response, exclusions=exclusions) if exclusions != '' else format_response(response)
436478

437479
# If DataFrame is empty, return early
438480
if df.empty:

app/slackbot.py

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,24 +70,20 @@
7070
client = WebClient(token=BOT_USER_TOKEN)
7171

7272

73-
def fmt_json(filename):
74-
# read json file
75-
data = json.load(open(filename))
76-
77-
# create dataframe
78-
df = pd.DataFrame(data)
79-
80-
# handle empty dataframe case
81-
if df.empty:
73+
def fmt_events(events: list[dict]) -> list[str]:
74+
"""Format a list of event dicts into Slack message strings."""
75+
if not events:
8276
return []
8377

84-
# add column: 'message' with date, name, title, eventUrl
78+
df = pd.DataFrame(events)
8579
df['message'] = df.apply(lambda x: f'• {x["date"]} *{x["name"]}* <{x["eventUrl"]}|{x["title"]}> ', axis=1)
80+
return df['message'].tolist()
8681

87-
# convert message column to list of strings (avoids alignment shenanigans)
88-
msg = df['message'].tolist()
8982

90-
return msg
83+
def fmt_json(filename):
84+
# read json file
85+
data = json.load(open(filename))
86+
return fmt_events(data)
9187

9288

9389
def send_message(message, channel_id):
Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
---
22
id: TASK-014
33
title: Decouple export from formatted response
4-
status: To Do
5-
assignee: []
4+
status: Done
5+
assignee:
6+
- Claude
67
created_date: '2026-02-27 00:54'
7-
updated_date: '2026-03-17 02:09'
8+
updated_date: '2026-03-21 00:01'
89
labels:
910
- refactor
1011
dependencies: []
@@ -13,7 +14,7 @@ references:
1314
- app/meetup_query.py
1415
- app/slackbot.py
1516
priority: medium
16-
ordinal: 7000
17+
ordinal: 3750
1718
---
1819

1920
## Description
@@ -24,8 +25,55 @@ Currently export_to_file calls format_response internally, and slackbot reads fr
2425

2526
## Acceptance Criteria
2627
<!-- AC:BEGIN -->
27-
- [ ] #1 format_response returns structured data without writing to disk
28-
- [ ] #2 export_to_file accepts pre-formatted data (no internal format_response call)
29-
- [ ] #3 slackbot can receive JSON data directly instead of reading from file
30-
- [ ] #4 Existing /api/events and /api/slack endpoints work unchanged
28+
- [x] #1 format_response returns structured data without writing to disk
29+
- [x] #2 export_to_file accepts pre-formatted data (no internal format_response call)
30+
- [x] #3 slackbot can receive JSON data directly instead of reading from file
31+
- [x] #4 Existing /api/events and /api/slack endpoints work unchanged
3132
<!-- AC:END -->
33+
34+
## Implementation Plan
35+
36+
<!-- SECTION:PLAN:BEGIN -->
37+
## Implementation Plan
38+
39+
### 1. `export_to_file` accepts pre-formatted DataFrame (AC #2)
40+
- Add optional `df` parameter to `export_to_file()`
41+
- If `df` is provided, skip the internal `format_response()` call
42+
- If `df` is None (backward compat for `meetup_query.main()`), call `format_response()` as before
43+
44+
### 2. `slackbot` can receive JSON data directly (AC #3)
45+
- Add `fmt_events(events: list[dict])` to `slackbot.py` — same logic as `fmt_json` but takes data directly
46+
- Keep `fmt_json()` for backward compatibility (`slackbot.main()` still uses it)
47+
48+
### 3. Refactor `main.py` endpoints for in-memory data flow (AC #4)
49+
- `get_events()`: Call `format_response()` directly, collect DataFrames, concat, sort/dedupe in memory, return structured data
50+
- `post_slack()`: Use return value from `get_events()`, pass to `fmt_events()` instead of reading file
51+
52+
### 4. Update tests
53+
- `test_export_to_file` — test passing a pre-formatted DataFrame
54+
- Add `test_fmt_events` — test new function with in-memory data
55+
- `test_get_events` — remove file mocks, verify in-memory flow
56+
- `test_post_slack` — verify `fmt_events` called instead of `fmt_json`
57+
<!-- SECTION:PLAN:END -->
58+
59+
## Implementation Notes
60+
61+
<!-- SECTION:NOTES:BEGIN -->
62+
## Changes Made
63+
64+
### app/meetup_query.py
65+
- `export_to_file()`: Added optional `df` parameter. When provided, skips internal `format_response()` call.
66+
- `prepare_events(df)`: New function extracted from `sort_json()` logic — deduplicates, normalizes dates, sorts, filters past events, formats dates. Returns `list[dict]` in memory.
67+
68+
### app/slackbot.py
69+
- `fmt_events(events)`: New function that formats a list of event dicts into Slack message strings (extracted from `fmt_json`).
70+
- `fmt_json()`: Refactored to delegate to `fmt_events()` for backward compatibility.
71+
72+
### app/main.py
73+
- `get_events()`: Collects DataFrames from `format_response()` calls, concatenates them, passes to `prepare_events()`. No file I/O.
74+
- `post_slack()`: Uses return value from `get_events()` and passes directly to `fmt_events()`. No file reading.
75+
76+
### tests/test_unit.py
77+
- Added: `test_export_to_file_with_preformatted_df`, `test_fmt_events`, `test_fmt_events_empty`, `test_prepare_events_deduplicates_and_sorts`, `test_prepare_events_empty`
78+
- Updated: `test_get_events`, `test_post_slack`, `test_post_slack_passes_auth_to_get_events` to reflect in-memory data flow
79+
<!-- SECTION:NOTES:END -->

tests/test_unit.py

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@
2020
format_response,
2121
http_client,
2222
main,
23+
prepare_events,
2324
send_batched_group_request,
2425
send_request,
2526
sort_csv,
2627
sort_json,
2728
)
2829
from pathlib import Path
30+
from slackbot import fmt_events
2931
from unittest.mock import MagicMock, patch
3032

3133
# ── Fixtures ────────────────────────────────────────────────────────
@@ -294,19 +296,15 @@ def test_get_events(test_client, auth_headers):
294296
}
295297
]
296298

299+
mock_df = pd.DataFrame(columns=["name", "date", "title", "description", "city", "eventUrl"])
300+
297301
with (
298302
patch('main.generate_token', return_value=("fake_access", "fake_refresh")),
299303
patch('main.send_request'),
300304
patch('main.send_batched_group_request', return_value=[]),
301-
patch('main.export_to_file'),
302-
patch('main.format_response', return_value=MagicMock(__len__=lambda s: 0)),
303-
patch('main.sort_json'),
304-
patch('main.os.path.exists', return_value=True),
305-
patch('main.os.stat', return_value=MagicMock(st_size=100)),
306-
patch('main.pd.read_json') as mock_read_json,
305+
patch('main.format_response', return_value=mock_df),
306+
patch('main.prepare_events', return_value=mock_events),
307307
):
308-
mock_read_json.return_value = MagicMock()
309-
mock_read_json.return_value.to_dict.return_value = mock_events
310308
response = test_client.get(
311309
"/api/events", headers=auth_headers, params={"location": "Oklahoma City", "exclusions": "Tulsa"}
312310
)
@@ -346,10 +344,11 @@ def db_session_passthrough(f=None, *a, **kw):
346344
@pytest.mark.unit
347345
def test_post_slack(test_client, auth_headers):
348346
mock_message = ["Test message"]
347+
mock_events = [{"name": "G", "date": "Thu 5/26 11:30 am", "title": "E", "eventUrl": "https://u"}]
349348

350349
with (
351-
patch('main.get_events'),
352-
patch('main.fmt_json', return_value=mock_message),
350+
patch('main.get_events', return_value=mock_events),
351+
patch('main.fmt_events', return_value=mock_message),
353352
patch('main.send_message'),
354353
patch('main.chan_dict', {"test-channel": "C12345"}),
355354
):
@@ -371,8 +370,8 @@ def test_post_slack_passes_auth_to_get_events(test_client, auth_headers):
371370
mock_message = ["Test message"]
372371

373372
with (
374-
patch('main.get_events') as mock_get_events,
375-
patch('main.fmt_json', return_value=mock_message),
373+
patch('main.get_events', return_value=[]) as mock_get_events,
374+
patch('main.fmt_events', return_value=mock_message),
376375
patch('main.send_message'),
377376
patch('main.chan_dict', {"test-channel": "C12345"}),
378377
):
@@ -955,6 +954,83 @@ def test_export_to_file(mock_response, tmp_path):
955954
assert exported_data[0]["title"] == "Test Event"
956955

957956

957+
@pytest.mark.unit
958+
def test_export_to_file_with_preformatted_df(tmp_path):
959+
"""export_to_file accepts a pre-formatted DataFrame, skipping format_response."""
960+
test_json = tmp_path / "output.json"
961+
df = pd.DataFrame(
962+
{
963+
"name": ["Pre Group"],
964+
"date": ["2024-09-20T18:00:00-05:00"],
965+
"title": ["Pre Event"],
966+
"description": ["Pre-formatted"],
967+
"city": ["Oklahoma City"],
968+
"eventUrl": ["https://www.meetup.com/pre/events/1/"],
969+
}
970+
)
971+
972+
with patch("meetup_query.json_fn", str(test_json)):
973+
export_to_file(None, type="json", df=df)
974+
975+
with open(test_json) as f:
976+
exported_data = json.load(f)
977+
978+
assert len(exported_data) == 1
979+
assert exported_data[0]["title"] == "Pre Event"
980+
981+
982+
@pytest.mark.unit
983+
def test_fmt_events():
984+
"""fmt_events formats a list of event dicts into Slack message strings."""
985+
events = [
986+
{
987+
"name": "Test Group",
988+
"date": "Thu 5/26 11:30 am",
989+
"title": "Test Event",
990+
"eventUrl": "https://test.url",
991+
}
992+
]
993+
result = fmt_events(events)
994+
assert len(result) == 1
995+
assert "Test Group" in result[0]
996+
assert "Test Event" in result[0]
997+
assert "https://test.url" in result[0]
998+
999+
1000+
@pytest.mark.unit
1001+
def test_fmt_events_empty():
1002+
"""fmt_events returns empty list for empty input."""
1003+
assert fmt_events([]) == []
1004+
1005+
1006+
@pytest.mark.unit
1007+
def test_prepare_events_deduplicates_and_sorts():
1008+
"""prepare_events deduplicates by eventUrl, sorts by date, drops past events."""
1009+
future = arrow.now("America/Chicago").shift(days=1).format("YYYY-MM-DDTHH:mm:ssZ")
1010+
future2 = arrow.now("America/Chicago").shift(days=2).format("YYYY-MM-DDTHH:mm:ssZ")
1011+
df = pd.DataFrame(
1012+
{
1013+
"name": ["A", "B", "A"],
1014+
"date": [future2, future, future2],
1015+
"title": ["Event 2", "Event 1", "Event 2 dup"],
1016+
"description": ["d2", "d1", "d2"],
1017+
"city": ["Oklahoma City"] * 3,
1018+
"eventUrl": ["https://url/2", "https://url/1", "https://url/2"],
1019+
}
1020+
)
1021+
result = prepare_events(df)
1022+
assert len(result) == 2
1023+
assert result[0]["eventUrl"] == "https://url/1"
1024+
assert result[1]["eventUrl"] == "https://url/2"
1025+
1026+
1027+
@pytest.mark.unit
1028+
def test_prepare_events_empty():
1029+
"""prepare_events returns empty list for empty DataFrame."""
1030+
df = pd.DataFrame(columns=["name", "date", "title", "description", "city", "eventUrl"])
1031+
assert prepare_events(df) == []
1032+
1033+
9581034
@pytest.mark.unit
9591035
@patch("meetup_query.gen_token")
9601036
@patch("meetup_query.send_request")

0 commit comments

Comments
 (0)