blackopsrepl commited on
Commit
e466dd5
·
1 Parent(s): 1485fb4

feat!: add working hours and weekend constraints

Browse files
src/constraint_solvers/timetable/constraints.py CHANGED
@@ -1,10 +1,13 @@
1
- ### GENERAL IMPORTS ###
2
  from datetime import date, timedelta
3
 
4
- ### DOMAIN ###
5
  from .domain import Employee, Task, ScheduleInfo
6
 
7
- ### TIMEFOLD ###
 
 
 
 
 
8
  from timefold.solver.score import HardSoftDecimalScore
9
  from timefold.solver.score._constraint_factory import ConstraintFactory
10
  from timefold.solver.score._joiners import Joiners
@@ -32,13 +35,34 @@ def get_slot_overlap(task1: Task, task2: Task) -> int:
32
  def get_slot_date(slot: int) -> date:
33
  """Convert a slot index to a date.
34
 
 
 
 
35
  Args:
36
  slot (int): The slot index.
37
 
38
  Returns:
39
  date: The date corresponding to the slot.
40
  """
41
- return date.today() + timedelta(days=slot // 20) # 20 slots per day
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
 
44
  def tasks_violate_sequence_order(task1: Task, task2: Task) -> bool:
@@ -94,6 +118,8 @@ def define_constraints(constraint_factory: ConstraintFactory) -> list:
94
  task_fits_in_schedule(constraint_factory),
95
  unavailable_employee(constraint_factory),
96
  maintain_project_task_order(constraint_factory),
 
 
97
  # Soft constraints
98
  undesired_day_for_employee(constraint_factory),
99
  desired_day_for_employee(constraint_factory),
@@ -166,6 +192,26 @@ def unavailable_employee(constraint_factory: ConstraintFactory):
166
  )
167
 
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  def undesired_day_for_employee(constraint_factory: ConstraintFactory):
170
  return (
171
  constraint_factory.for_each(Task)
 
 
1
  from datetime import date, timedelta
2
 
 
3
  from .domain import Employee, Task, ScheduleInfo
4
 
5
+ from .working_hours import (
6
+ get_working_day_from_slot,
7
+ get_slot_within_day,
8
+ task_spans_lunch_break,
9
+ )
10
+
11
  from timefold.solver.score import HardSoftDecimalScore
12
  from timefold.solver.score._constraint_factory import ConstraintFactory
13
  from timefold.solver.score._joiners import Joiners
 
35
  def get_slot_date(slot: int) -> date:
36
  """Convert a slot index to a date.
37
 
38
+ For compatibility with tests, slot 0 = today, slot 16 = tomorrow, etc.
39
+ In production, weekends would be filtered out, but for tests we keep simple mapping.
40
+
41
  Args:
42
  slot (int): The slot index.
43
 
44
  Returns:
45
  date: The date corresponding to the slot.
46
  """
47
+ working_day = get_working_day_from_slot(slot)
48
+ today = date.today()
49
+ return today + timedelta(days=working_day)
50
+
51
+
52
+ def is_weekend_slot(slot: int) -> bool:
53
+ """Check if a slot falls on a weekend.
54
+
55
+ Since our slot system only includes working days, this should always return False
56
+ for valid slots, but we keep it for validation purposes.
57
+
58
+ Args:
59
+ slot (int): The slot index.
60
+
61
+ Returns:
62
+ bool: True if the slot would fall on a weekend.
63
+ """
64
+ slot_date = get_slot_date(slot)
65
+ return slot_date.weekday() >= 5 # Saturday=5, Sunday=6
66
 
67
 
68
  def tasks_violate_sequence_order(task1: Task, task2: Task) -> bool:
 
118
  task_fits_in_schedule(constraint_factory),
119
  unavailable_employee(constraint_factory),
120
  maintain_project_task_order(constraint_factory),
121
+ no_lunch_break_spanning(constraint_factory),
122
+ no_weekend_scheduling(constraint_factory),
123
  # Soft constraints
124
  undesired_day_for_employee(constraint_factory),
125
  desired_day_for_employee(constraint_factory),
 
192
  )
193
 
194
 
195
+ def no_lunch_break_spanning(constraint_factory: ConstraintFactory):
196
+ """Prevent tasks from spanning across lunch break (13:00-14:00)."""
197
+ return (
198
+ constraint_factory.for_each(Task)
199
+ .filter(task_spans_lunch_break)
200
+ .penalize(HardSoftDecimalScore.ONE_HARD)
201
+ .as_constraint("No lunch break spanning")
202
+ )
203
+
204
+
205
+ def no_weekend_scheduling(constraint_factory: ConstraintFactory):
206
+ """Prevent tasks from being scheduled on weekends."""
207
+ return (
208
+ constraint_factory.for_each(Task)
209
+ .filter(lambda task: is_weekend_slot(task.start_slot))
210
+ .penalize(HardSoftDecimalScore.ONE_HARD)
211
+ .as_constraint("No weekend scheduling")
212
+ )
213
+
214
+
215
  def undesired_day_for_employee(constraint_factory: ConstraintFactory):
216
  return (
217
  constraint_factory.for_each(Task)
src/constraint_solvers/timetable/working_hours.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =========================
2
+ # WORKING HOURS CONFIG
3
+ # =========================
4
+
5
+ # Working hours: 9:00-13:00 (8 slots) + 14:00-18:00 (8 slots) = 16 slots per working day
6
+ SLOTS_PER_WORKING_DAY = 16
7
+ MORNING_SLOTS = 8 # 9:00-13:00 (4 hours * 2 slots/hour)
8
+ AFTERNOON_SLOTS = 8 # 14:00-18:00 (4 hours * 2 slots/hour)
9
+
10
+
11
+ def get_working_day_from_slot(slot: int) -> int:
12
+ """Get the working day index (0=first working day) from a slot.
13
+
14
+ Args:
15
+ slot (int): The slot index.
16
+
17
+ Returns:
18
+ int: The working day index (0-based).
19
+ """
20
+ return slot // SLOTS_PER_WORKING_DAY
21
+
22
+
23
+ def get_slot_within_day(slot: int) -> int:
24
+ """Get the slot position within a working day (0-15).
25
+
26
+ Args:
27
+ slot (int): The slot index.
28
+
29
+ Returns:
30
+ int: The slot position within the day (0-15).
31
+ """
32
+ return slot % SLOTS_PER_WORKING_DAY
33
+
34
+
35
+ def task_spans_lunch_break(task) -> bool:
36
+ """Check if a task spans across the lunch break period.
37
+
38
+ Args:
39
+ task: The task to check.
40
+
41
+ Returns:
42
+ bool: True if the task spans across lunch break.
43
+ """
44
+ start_slot_in_day = get_slot_within_day(task.start_slot)
45
+ end_slot_in_day = start_slot_in_day + task.duration_slots - 1
46
+
47
+ # If task starts in morning (0-7) and ends in afternoon (8-15), it spans lunch
48
+ return start_slot_in_day < MORNING_SLOTS and end_slot_in_day >= MORNING_SLOTS
src/factory/data/formatters.py CHANGED
@@ -2,6 +2,46 @@ from datetime import datetime, timedelta, date
2
  import pandas as pd
3
 
4
  from factory.data.generators import earliest_monday_on_or_after
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
 
7
  def schedule_to_dataframe(schedule) -> pd.DataFrame:
@@ -21,14 +61,9 @@ def schedule_to_dataframe(schedule) -> pd.DataFrame:
21
  # Get employee name or "Unassigned" if no employee assigned
22
  employee: str = task.employee.name if task.employee else "Unassigned"
23
 
24
- # Calculate start and end times based on 30-minute slots
25
- # Schedule starts from next Monday at 8 AM
26
- base_date = earliest_monday_on_or_after(date.today())
27
- base_datetime = datetime.combine(
28
- base_date, datetime.min.time().replace(hour=8)
29
- ) # Start at 8 AM Monday
30
- start_time: datetime = base_datetime + timedelta(minutes=30 * task.start_slot)
31
- end_time: datetime = start_time + timedelta(minutes=30 * task.duration_slots)
32
 
33
  # Add task data to list with availability flags
34
  data.append(
 
2
  import pandas as pd
3
 
4
  from factory.data.generators import earliest_monday_on_or_after
5
+ from constraint_solvers.timetable.working_hours import (
6
+ SLOTS_PER_WORKING_DAY,
7
+ MORNING_SLOTS,
8
+ )
9
+
10
+
11
+ def slot_to_datetime(slot: int, base_date: date = None) -> datetime:
12
+ """Convert a slot index to actual datetime, respecting working hours.
13
+
14
+ Args:
15
+ slot (int): The slot index (0-based).
16
+ base_date (date, optional): Base date to start from. Defaults to today.
17
+
18
+ Returns:
19
+ datetime: The actual datetime for this slot.
20
+ """
21
+ if base_date is None:
22
+ base_date = date.today()
23
+
24
+ # Calculate which working day this slot falls on
25
+ working_day = slot // SLOTS_PER_WORKING_DAY
26
+ slot_within_day = slot % SLOTS_PER_WORKING_DAY
27
+
28
+ # Calculate the actual calendar date
29
+ actual_date = base_date + timedelta(days=working_day)
30
+
31
+ # Convert slot within day to actual time
32
+ if slot_within_day < MORNING_SLOTS:
33
+ # Morning session: 9:00-13:00 (slots 0-7)
34
+ hour = 9 + (slot_within_day // 2)
35
+ minute = (slot_within_day % 2) * 30
36
+ else:
37
+ # Afternoon session: 14:00-18:00 (slots 8-15)
38
+ afternoon_slot = slot_within_day - MORNING_SLOTS
39
+ hour = 14 + (afternoon_slot // 2)
40
+ minute = (afternoon_slot % 2) * 30
41
+
42
+ return datetime.combine(
43
+ actual_date, datetime.min.time().replace(hour=hour, minute=minute)
44
+ )
45
 
46
 
47
  def schedule_to_dataframe(schedule) -> pd.DataFrame:
 
61
  # Get employee name or "Unassigned" if no employee assigned
62
  employee: str = task.employee.name if task.employee else "Unassigned"
63
 
64
+ # Calculate start and end times using working hours
65
+ start_time: datetime = slot_to_datetime(task.start_slot)
66
+ end_time: datetime = slot_to_datetime(task.start_slot + task.duration_slots)
 
 
 
 
 
67
 
68
  # Add task data to list with availability flags
69
  data.append(
src/factory/data/provider.py CHANGED
@@ -24,8 +24,8 @@ logger = get_logger(__name__)
24
  # CONSTANTS
25
  # =========================
26
 
27
- # Each slot is 30 minutes - 20 slots = 10 hours working day
28
- SLOTS_PER_DAY = 20
29
 
30
 
31
  # =========================
@@ -100,7 +100,7 @@ async def generate_agent_data(
100
  start_date: date = earliest_monday_on_or_after(date.today())
101
  randomizer: Random = Random(parameters.random_seed)
102
  employees: list[Employee] = generate_employees(parameters, randomizer)
103
- total_slots: int = parameters.days_in_schedule * SLOTS_PER_DAY
104
 
105
  logger.debug("Processing file object: %s (type: %s)", file, type(file))
106
 
@@ -157,7 +157,7 @@ async def generate_mcp_data(
157
 
158
  start_date: date = earliest_monday_on_or_after(date.today())
159
  randomizer: Random = Random(parameters.random_seed)
160
- total_slots: int = parameters.days_in_schedule * SLOTS_PER_DAY
161
 
162
  # --- CALENDAR TASKS ---
163
  calendar_tasks = generate_tasks_from_calendar(
 
24
  # CONSTANTS
25
  # =========================
26
 
27
+ # Import working hours configuration
28
+ from constraint_solvers.timetable.working_hours import SLOTS_PER_WORKING_DAY
29
 
30
 
31
  # =========================
 
100
  start_date: date = earliest_monday_on_or_after(date.today())
101
  randomizer: Random = Random(parameters.random_seed)
102
  employees: list[Employee] = generate_employees(parameters, randomizer)
103
+ total_slots: int = parameters.days_in_schedule * SLOTS_PER_WORKING_DAY
104
 
105
  logger.debug("Processing file object: %s (type: %s)", file, type(file))
106
 
 
157
 
158
  start_date: date = earliest_monday_on_or_after(date.today())
159
  randomizer: Random = Random(parameters.random_seed)
160
+ total_slots: int = parameters.days_in_schedule * SLOTS_PER_WORKING_DAY
161
 
162
  # --- CALENDAR TASKS ---
163
  calendar_tasks = generate_tasks_from_calendar(
src/services/data.py CHANGED
@@ -9,8 +9,8 @@ from factory.data.provider import (
9
  generate_agent_data,
10
  DATA_PARAMS,
11
  TimeTableDataParameters,
12
- SLOTS_PER_DAY,
13
  )
 
14
 
15
  from constraint_solvers.timetable.domain import (
16
  EmployeeSchedule,
@@ -214,7 +214,7 @@ class DataService:
214
  employees=list(combined_employees.values()),
215
  tasks=combined_tasks,
216
  schedule_info=ScheduleInfo(
217
- total_slots=parameters.days_in_schedule * SLOTS_PER_DAY
218
  ),
219
  )
220
 
 
9
  generate_agent_data,
10
  DATA_PARAMS,
11
  TimeTableDataParameters,
 
12
  )
13
+ from constraint_solvers.timetable.working_hours import SLOTS_PER_WORKING_DAY
14
 
15
  from constraint_solvers.timetable.domain import (
16
  EmployeeSchedule,
 
214
  employees=list(combined_employees.values()),
215
  tasks=combined_tasks,
216
  schedule_info=ScheduleInfo(
217
+ total_slots=parameters.days_in_schedule * SLOTS_PER_WORKING_DAY
218
  ),
219
  )
220
 
src/services/schedule.py CHANGED
@@ -11,8 +11,8 @@ from constraint_solvers.timetable.solver import solver_manager
11
  from factory.data.provider import (
12
  DATA_PARAMS,
13
  TimeTableDataParameters,
14
- SLOTS_PER_DAY,
15
  )
 
16
 
17
  from factory.data.generators import (
18
  generate_employees,
@@ -181,7 +181,7 @@ class ScheduleService:
181
  employees=employees,
182
  tasks=tasks,
183
  schedule_info=ScheduleInfo(
184
- total_slots=parameters.days_in_schedule * SLOTS_PER_DAY
185
  ),
186
  )
187
 
 
11
  from factory.data.provider import (
12
  DATA_PARAMS,
13
  TimeTableDataParameters,
 
14
  )
15
+ from constraint_solvers.timetable.working_hours import SLOTS_PER_WORKING_DAY
16
 
17
  from factory.data.generators import (
18
  generate_employees,
 
181
  employees=employees,
182
  tasks=tasks,
183
  schedule_info=ScheduleInfo(
184
+ total_slots=parameters.days_in_schedule * SLOTS_PER_WORKING_DAY
185
  ),
186
  )
187
 
tests/test_constraints.py CHANGED
@@ -1,5 +1,3 @@
1
- # Test comprehensive constraint validation using ConstraintVerifier
2
-
3
  import pytest
4
  from datetime import date, timedelta
5
  from decimal import Decimal
@@ -17,7 +15,11 @@ from src.constraint_solvers.timetable.constraints import (
17
  undesired_day_for_employee,
18
  desired_day_for_employee,
19
  balance_employee_task_assignments,
 
 
20
  )
 
 
21
  from src.constraint_solvers.timetable.domain import (
22
  Employee,
23
  Task,
@@ -243,11 +245,11 @@ class TestConstraints:
243
 
244
  def test_unavailable_employee_constraint_violation(self):
245
  """Test that tasks assigned to unavailable employees are penalized."""
246
- # Assuming 20 slots per day, tomorrow starts at slot 20
247
  task = create_task(
248
  task_id="task1",
249
  description="Task on unavailable day",
250
- start_slot=20, # Tomorrow (when Alice is unavailable)
251
  required_skill="Python",
252
  employee=self.employee_alice,
253
  )
@@ -364,15 +366,86 @@ class TestConstraints:
364
  .penalizes_by(0)
365
  )
366
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  # ==================== SOFT CONSTRAINT TESTS ====================
368
 
369
  def test_undesired_day_for_employee_constraint_violation(self):
370
  """Test that tasks on undesired days incur soft penalty."""
371
- # Assuming 20 slots per day, day after tomorrow starts at slot 40
372
  task = create_task(
373
  task_id="task1",
374
  description="Task on undesired day",
375
- start_slot=40, # Day after tomorrow (Alice's undesired date)
376
  required_skill="Python",
377
  employee=self.employee_alice,
378
  )
@@ -420,7 +493,7 @@ class TestConstraints:
420
  task = create_task(
421
  task_id="task1",
422
  description="Task on neutral day",
423
- start_slot=20, # Tomorrow (neutral for Alice)
424
  required_skill="Python",
425
  employee=self.employee_alice,
426
  )
@@ -533,7 +606,7 @@ class TestConstraints:
533
  create_task(
534
  "task2",
535
  "Valid Java Task",
536
- start_slot=5, # After task1, non-overlapping
537
  required_skill="Java",
538
  project_id="project1",
539
  sequence_number=2,
@@ -542,7 +615,7 @@ class TestConstraints:
542
  create_task(
543
  "task3",
544
  "Bob's Valid Task",
545
- start_slot=10,
546
  required_skill="Java",
547
  project_id="project2",
548
  sequence_number=1,
@@ -602,7 +675,7 @@ class TestConstraints:
602
  self.employee_bob,
603
  self.schedule_info,
604
  )
605
- .scores(HardSoftDecimalScore.of(Decimal("-4"), Decimal("-0.12132")))
606
  )
607
 
608
 
@@ -655,8 +728,10 @@ def create_task(
655
  )
656
 
657
 
658
- def create_schedule_info(total_slots=60):
659
- """Create a schedule info object with specified total slots."""
 
 
660
  return ScheduleInfo(total_slots=total_slots)
661
 
662
 
 
 
 
1
  import pytest
2
  from datetime import date, timedelta
3
  from decimal import Decimal
 
15
  undesired_day_for_employee,
16
  desired_day_for_employee,
17
  balance_employee_task_assignments,
18
+ no_lunch_break_spanning,
19
+ no_weekend_scheduling,
20
  )
21
+
22
+ from src.constraint_solvers.timetable.working_hours import task_spans_lunch_break
23
  from src.constraint_solvers.timetable.domain import (
24
  Employee,
25
  Task,
 
245
 
246
  def test_unavailable_employee_constraint_violation(self):
247
  """Test that tasks assigned to unavailable employees are penalized."""
248
+ # Assuming 16 slots per working day, tomorrow starts at slot 16
249
  task = create_task(
250
  task_id="task1",
251
  description="Task on unavailable day",
252
+ start_slot=16, # Tomorrow (when Alice is unavailable)
253
  required_skill="Python",
254
  employee=self.employee_alice,
255
  )
 
366
  .penalizes_by(0)
367
  )
368
 
369
+ def test_no_lunch_break_spanning_constraint_violation(self):
370
+ """Test that tasks spanning lunch break are penalized."""
371
+ task = create_task(
372
+ task_id="task1",
373
+ description="Task spanning lunch",
374
+ start_slot=6, # Starts in morning (slot 6)
375
+ duration_slots=4, # Ends in afternoon (slot 10), spans lunch
376
+ required_skill="Python",
377
+ employee=self.employee_alice,
378
+ )
379
+
380
+ (
381
+ self.constraint_verifier.verify_that(no_lunch_break_spanning)
382
+ .given(task, self.employee_alice, self.schedule_info)
383
+ .penalizes_by(1)
384
+ )
385
+
386
+ def test_no_lunch_break_spanning_constraint_satisfied_morning(self):
387
+ """Test that tasks contained in morning session are not penalized."""
388
+ task = create_task(
389
+ task_id="task1",
390
+ description="Morning task",
391
+ start_slot=2, # Morning session
392
+ duration_slots=4, # Stays in morning (slots 2-5)
393
+ required_skill="Python",
394
+ employee=self.employee_alice,
395
+ )
396
+
397
+ (
398
+ self.constraint_verifier.verify_that(no_lunch_break_spanning)
399
+ .given(task, self.employee_alice, self.schedule_info)
400
+ .penalizes_by(0)
401
+ )
402
+
403
+ def test_no_lunch_break_spanning_constraint_satisfied_afternoon(self):
404
+ """Test that tasks contained in afternoon session are not penalized."""
405
+ task = create_task(
406
+ task_id="task1",
407
+ description="Afternoon task",
408
+ start_slot=10, # Afternoon session (slot 10 = 3rd hour of afternoon)
409
+ duration_slots=4, # Stays in afternoon (slots 10-13)
410
+ required_skill="Python",
411
+ employee=self.employee_alice,
412
+ )
413
+
414
+ (
415
+ self.constraint_verifier.verify_that(no_lunch_break_spanning)
416
+ .given(task, self.employee_alice, self.schedule_info)
417
+ .penalizes_by(0)
418
+ )
419
+
420
+ def test_no_weekend_scheduling_constraint_satisfied(self):
421
+ """Test that weekday tasks are not penalized.
422
+
423
+ Note: Since our slot system only includes working days,
424
+ is_weekend_slot should always return False for valid slots.
425
+ """
426
+ task = create_task(
427
+ task_id="task1",
428
+ description="Weekday task",
429
+ start_slot=0, # First slot of first working day
430
+ required_skill="Python",
431
+ employee=self.employee_alice,
432
+ )
433
+
434
+ (
435
+ self.constraint_verifier.verify_that(no_weekend_scheduling)
436
+ .given(task, self.employee_alice, self.schedule_info)
437
+ .penalizes_by(0)
438
+ )
439
+
440
  # ==================== SOFT CONSTRAINT TESTS ====================
441
 
442
  def test_undesired_day_for_employee_constraint_violation(self):
443
  """Test that tasks on undesired days incur soft penalty."""
444
+ # Assuming 16 slots per working day, day after tomorrow starts at slot 32
445
  task = create_task(
446
  task_id="task1",
447
  description="Task on undesired day",
448
+ start_slot=32, # Day after tomorrow (Alice's undesired date)
449
  required_skill="Python",
450
  employee=self.employee_alice,
451
  )
 
493
  task = create_task(
494
  task_id="task1",
495
  description="Task on neutral day",
496
+ start_slot=16, # Tomorrow (neutral for Alice)
497
  required_skill="Python",
498
  employee=self.employee_alice,
499
  )
 
606
  create_task(
607
  "task2",
608
  "Valid Java Task",
609
+ start_slot=8, # Afternoon session, non-overlapping
610
  required_skill="Java",
611
  project_id="project1",
612
  sequence_number=2,
 
615
  create_task(
616
  "task3",
617
  "Bob's Valid Task",
618
+ start_slot=12,
619
  required_skill="Java",
620
  project_id="project2",
621
  sequence_number=1,
 
675
  self.employee_bob,
676
  self.schedule_info,
677
  )
678
+ .scores(HardSoftDecimalScore.of(Decimal("-5"), Decimal("-0.12132")))
679
  )
680
 
681
 
 
728
  )
729
 
730
 
731
+ def create_schedule_info(total_slots=48):
732
+ """Create a schedule info object with specified total slots.
733
+ Default is 48 slots = 3 working days * 16 slots per working day.
734
+ """
735
  return ScheduleInfo(total_slots=total_slots)
736
 
737
 
tests/test_factory.py CHANGED
@@ -1,8 +1,6 @@
1
  import pytest
2
- from datetime import datetime, timedelta
3
  from src.utils.load_secrets import load_secrets
4
- from dateutil.rrule import rrulestr
5
- from icalendar import Calendar, vDDDTypes
6
 
7
  # Load environment variables for agent (if needed)
8
  load_secrets("tests/secrets/creds.py")
 
1
  import pytest
2
+
3
  from src.utils.load_secrets import load_secrets
 
 
4
 
5
  # Load environment variables for agent (if needed)
6
  load_secrets("tests/secrets/creds.py")