libres 0.9.1__py3-none-any.whl → 0.10.1__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.
libres/__init__.py CHANGED
@@ -3,9 +3,9 @@ from __future__ import annotations
3
3
  from libres.context.registry import create_default_registry
4
4
  from libres.db import new_scheduler
5
5
 
6
- registry = create_default_registry()
6
+ registry = create_default_registry() # noqa: RUF067
7
7
 
8
- __version__ = '0.9.1'
8
+ __version__ = '0.10.1'
9
9
  __all__ = (
10
10
  'new_scheduler',
11
11
  'registry'
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from libres.db.models.base import ORMBase
4
4
  from libres.db.models.allocation import Allocation
5
+ from libres.db.models.blocker import ReservationBlocker
5
6
  from libres.db.models.reserved_slot import ReservedSlot
6
7
  from libres.db.models.reservation import Reservation
7
8
 
@@ -10,5 +11,6 @@ __all__ = (
10
11
  'ORMBase',
11
12
  'Allocation',
12
13
  'ReservedSlot',
13
- 'Reservation'
14
+ 'Reservation',
15
+ 'ReservationBlocker',
14
16
  )
@@ -10,9 +10,10 @@ from sqlalchemy.schema import Column
10
10
  from sqlalchemy.schema import Index
11
11
  from sqlalchemy.schema import UniqueConstraint
12
12
  from sqlalchemy.orm import object_session
13
+ from sqlalchemy.orm import relationship
13
14
  from sqlalchemy.orm.util import has_identity
14
15
 
15
- from libres.db.models import ORMBase
16
+ from libres.db.models.base import ORMBase
16
17
  from libres.db.models.types import UUID, UTCDateTime, JSON
17
18
  from libres.db.models.other import OtherModels
18
19
  from libres.db.models.timestamp import TimestampMixin
@@ -83,7 +84,7 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
83
84
  resource: Column[uuid.UUID] = Column(UUID(), nullable=False)
84
85
 
85
86
  #: the polymorphic type of the allocation
86
- type: Column[str | None] = Column(types.Text(), nullable=True)
87
+ type: Column[str] = Column(types.Text(), nullable=False, default='generic')
87
88
 
88
89
  #: resource of which this allocation is a mirror. If the mirror_of
89
90
  #: attribute equals the resource, this is a real resource
@@ -136,9 +137,15 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
136
137
  nullable=False
137
138
  )
138
139
 
139
- if TYPE_CHECKING:
140
- # forward declare backref
141
- reserved_slots: list[ReservedSlot]
140
+ # Reserved_slots are eagerly joined since we usually want both
141
+ # allocation and reserved_slots. There's barely a function which does
142
+ # not need to know about reserved slots when working with allocations.
143
+ reserved_slots: relationship[list[ReservedSlot]] = relationship(
144
+ 'ReservedSlot',
145
+ lazy='joined',
146
+ cascade='all, delete-orphan',
147
+ back_populates='allocation'
148
+ )
142
149
 
143
150
  __table_args__ = (
144
151
  Index('mirror_resource_ix', 'mirror_of', 'resource'),
@@ -146,7 +153,7 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
146
153
  )
147
154
 
148
155
  __mapper_args__ = {
149
- 'polymorphic_identity': None,
156
+ 'polymorphic_identity': 'generic',
150
157
  'polymorphic_on': type
151
158
  }
152
159
 
@@ -502,15 +509,32 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
502
509
  """Returns the availability in percent."""
503
510
 
504
511
  total = self.count_slots()
505
- used = len(self.reserved_slots)
512
+ blocked = sum(
513
+ 1
514
+ for s in self.reserved_slots
515
+ if s.source_type == 'blocker'
516
+ )
517
+ reserved = sum(
518
+ 1
519
+ for s in self.reserved_slots
520
+ if s.source_type == 'reservation'
521
+ )
506
522
 
507
- if total == used:
523
+ if total == blocked:
524
+ # if everything is blocked this allocation is unavailable
508
525
  return 0.0
509
526
 
510
- if used == 0:
527
+ # blockers detract from the total slots
528
+ # they're not part of the availability
529
+ total -= blocked
530
+
531
+ if total == reserved:
532
+ return 0.0
533
+
534
+ if reserved == 0:
511
535
  return 100.0
512
536
 
513
- return 100.0 - (float(used) / float(total) * 100.0)
537
+ return 100.0 - 100.0 * (reserved / total)
514
538
 
515
539
  @property
516
540
  def normalized_availability(self) -> float:
@@ -542,27 +566,53 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
542
566
  # the normalized total slots correspond to the naive delta
543
567
  total = naive_delta.total_seconds() // (self.raster * 60)
544
568
  if real_delta > naive_delta:
545
- # this is the most complicated case since we need to
546
- # reduce the set of reserved slots by the hour we skipped
569
+ # this is the most complicated case since we need to reduce the
570
+ # set of reserved slots by the hour we removed from the total
547
571
  ambiguous_start = start.replace(
548
572
  hour=2, minute=0, second=0, microsecond=0)
549
573
  ambiguous_end = ambiguous_start.replace(hour=3)
550
- used = sum(
551
- 1 for r in self.reserved_slots
574
+ blocked = sum(
575
+ 1
576
+ for r in self.reserved_slots
577
+ if r.source_type == 'blocker'
578
+ if not ambiguous_start <= r.start < ambiguous_end
579
+ )
580
+ reserved = sum(
581
+ 1
582
+ for r in self.reserved_slots
583
+ if r.source_type == 'reservation'
552
584
  if not ambiguous_start <= r.start < ambiguous_end
553
585
  )
554
586
  else:
555
- used = len(self.reserved_slots)
556
- # add one hour's worth of reserved slots
557
- used += 60 // self.raster
587
+ blocked = sum(
588
+ 1
589
+ for s in self.reserved_slots
590
+ if s.source_type == 'blocker'
591
+ )
592
+ reserved = sum(
593
+ 1
594
+ for s in self.reserved_slots
595
+ if s.source_type == 'reservation'
596
+ )
597
+ # add one hour's worth of slots to compensate for the extra
598
+ # hour we added to the total.
599
+ reserved += 60 // self.raster
558
600
 
559
- if used == 0:
560
- return 100.0
601
+ if total == blocked:
602
+ # if everything is blocked this allocation is unavailable
603
+ return 0.0
604
+
605
+ # blockers detract from the total slots
606
+ # they're not part of the availability
607
+ total -= blocked
561
608
 
562
- if total == used:
609
+ if total == reserved:
563
610
  return 0.0
564
611
 
565
- return 100.0 - (float(used) / float(total) * 100.0)
612
+ if reserved == 0:
613
+ return 100.0
614
+
615
+ return 100.0 - 100.0 * (reserved / total)
566
616
 
567
617
  @property
568
618
  def in_group(self) -> int:
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import sedate
4
+
5
+ from datetime import datetime, timedelta
6
+
7
+ from sqlalchemy import types
8
+ from sqlalchemy.orm import object_session
9
+ from sqlalchemy.schema import Column
10
+ from sqlalchemy.schema import Index
11
+
12
+ from libres.db.models.base import ORMBase
13
+ from libres.db.models.types import UUID, UTCDateTime
14
+ from libres.db.models.other import OtherModels
15
+ from libres.db.models.timespan import Timespan
16
+ from libres.db.models.timestamp import TimestampMixin
17
+
18
+
19
+ from typing import Literal
20
+ from typing import TYPE_CHECKING
21
+ if TYPE_CHECKING:
22
+ import uuid
23
+ from sedate.types import TzInfoOrName
24
+ from sqlalchemy.orm import Query
25
+
26
+ from libres.db.models import Allocation
27
+
28
+
29
+ class ReservationBlocker(TimestampMixin, ORMBase, OtherModels):
30
+ """Describes a reservation blocker.
31
+
32
+ Blockers can be used to signify that an allocation will be blocked for
33
+ the specified time span, in order to e.g. perform cleaning duties on
34
+ the relevant resource.
35
+
36
+ """
37
+
38
+ __tablename__ = 'reservation_blockers'
39
+
40
+ id: Column[int] = Column(
41
+ types.Integer(),
42
+ primary_key=True,
43
+ autoincrement=True
44
+ )
45
+
46
+ token: Column[uuid.UUID] = Column(
47
+ UUID(),
48
+ nullable=False,
49
+ )
50
+
51
+ target: Column[uuid.UUID] = Column(
52
+ UUID(),
53
+ nullable=False,
54
+ )
55
+
56
+ target_type: Column[Literal['group', 'allocation']] = Column(
57
+ types.Enum( # type:ignore[arg-type]
58
+ 'group', 'allocation',
59
+ name='reservation_blocker_target_type'
60
+ ),
61
+ nullable=False
62
+ )
63
+
64
+ resource: Column[uuid.UUID] = Column(
65
+ UUID(),
66
+ nullable=False
67
+ )
68
+
69
+ start: Column[datetime | None] = Column(
70
+ UTCDateTime(timezone=False),
71
+ nullable=True
72
+ )
73
+
74
+ end: Column[datetime | None] = Column(
75
+ UTCDateTime(timezone=False),
76
+ nullable=True
77
+ )
78
+
79
+ timezone: Column[str | None] = Column(
80
+ types.String(),
81
+ nullable=True
82
+ )
83
+
84
+ reason: Column[str | None] = Column(
85
+ types.String(),
86
+ nullable=True
87
+ )
88
+
89
+ __table_args__ = (
90
+ Index('blocker_target_ix', 'target', 'id'),
91
+ )
92
+
93
+ def target_allocations(
94
+ self,
95
+ masters_only: bool = True
96
+ ) -> Query[Allocation]:
97
+ """ Returns the allocations this blocker is targeting.
98
+
99
+ """
100
+ Allocation = self.models.Allocation # noqa: N806
101
+ query = object_session(self).query(Allocation)
102
+ query = query.filter(Allocation.group == self.target)
103
+
104
+ if masters_only:
105
+ query = query.filter(Allocation.resource == Allocation.mirror_of)
106
+
107
+ # order by date
108
+ query = query.order_by(Allocation._start)
109
+
110
+ return query # type: ignore[no-any-return]
111
+
112
+ def display_start(
113
+ self,
114
+ timezone: TzInfoOrName | None = None
115
+ ) -> datetime:
116
+ """Does nothing but to form a nice pair to display_end."""
117
+ assert self.start is not None
118
+ if timezone is None:
119
+ assert self.timezone is not None
120
+ timezone = self.timezone
121
+ return sedate.to_timezone(self.start, timezone)
122
+
123
+ def display_end(
124
+ self,
125
+ timezone: TzInfoOrName | None = None
126
+ ) -> datetime:
127
+ """Returns the end plus one microsecond (nicer display)."""
128
+ assert self.end is not None
129
+ if timezone is None:
130
+ assert self.timezone is not None
131
+ timezone = self.timezone
132
+
133
+ end = self.end + timedelta(microseconds=1)
134
+ return sedate.to_timezone(end, timezone)
135
+
136
+ def timespans(self) -> list[Timespan]:
137
+ """ Returns the timespans targeted by this blocker.
138
+
139
+ The result is a list of :class:`~libres.db.models.timespan.Timespan`
140
+ timespans. The start and end are the start and end dates of the
141
+ referenced allocations.
142
+
143
+ The timespans are ordered by start.
144
+
145
+ """
146
+
147
+ if self.target_type == 'allocation':
148
+ # we don't need to hit the database in this case
149
+ assert self.start is not None
150
+ assert self.end is not None
151
+ return [
152
+ Timespan(self.start, self.end + timedelta(microseconds=1))
153
+ ]
154
+ elif self.target_type == 'group':
155
+ return [
156
+ Timespan(allocation.start, allocation.end)
157
+ for allocation in self.target_allocations()
158
+ ]
159
+ else:
160
+ raise NotImplementedError
161
+
162
+ @property
163
+ def title(self) -> str | None:
164
+ return self.reason
libres/db/models/other.py CHANGED
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
11
11
  Allocation: type[_models.Allocation]
12
12
  ReservedSlot: type[_models.ReservedSlot]
13
13
  Reservation: type[_models.Reservation]
14
+ ReservationBlocker: type[_models.ReservationBlocker]
14
15
 
15
16
 
16
17
  models = None
@@ -25,7 +26,7 @@ class OtherModels:
25
26
  global models
26
27
  if not models:
27
28
  # FIXME: libres.db exports ORMBase, do we really
28
- # want to makes this accesible?
29
+ # want to makes this accessible?
29
30
  from libres.db import models as m_
30
31
  models = m_
31
32
 
@@ -9,15 +9,15 @@ from sqlalchemy.orm import object_session, deferred
9
9
  from sqlalchemy.schema import Column
10
10
  from sqlalchemy.schema import Index
11
11
 
12
- from libres.db.models import ORMBase
12
+ from libres.db.models.base import ORMBase
13
13
  from libres.db.models.types import UUID, UTCDateTime, JSON
14
14
  from libres.db.models.other import OtherModels
15
+ from libres.db.models.timespan import Timespan
15
16
  from libres.db.models.timestamp import TimestampMixin
16
17
 
17
18
 
18
19
  from typing import Any
19
20
  from typing import Literal
20
- from typing import NamedTuple
21
21
  from typing import TYPE_CHECKING
22
22
  if TYPE_CHECKING:
23
23
  import uuid
@@ -27,11 +27,6 @@ if TYPE_CHECKING:
27
27
  from libres.db.models import Allocation
28
28
 
29
29
 
30
- class Timespan(NamedTuple):
31
- start: datetime
32
- end: datetime
33
-
34
-
35
30
  class Reservation(TimestampMixin, ORMBase, OtherModels):
36
31
  """Describes a pending or approved reservation.
37
32
 
@@ -65,7 +60,8 @@ class Reservation(TimestampMixin, ORMBase, OtherModels):
65
60
 
66
61
  type: Column[str | None] = Column(
67
62
  types.Text(),
68
- nullable=True
63
+ nullable=False,
64
+ default='generic'
69
65
  )
70
66
 
71
67
  resource: Column[uuid.UUID] = Column(
@@ -122,7 +118,7 @@ class Reservation(TimestampMixin, ORMBase, OtherModels):
122
118
  )
123
119
 
124
120
  __mapper_args__ = {
125
- 'polymorphic_identity': None,
121
+ 'polymorphic_identity': 'generic',
126
122
  'polymorphic_on': type
127
123
  }
128
124
 
@@ -8,18 +8,19 @@ from sqlalchemy.schema import Column
8
8
  from sqlalchemy.schema import Index
9
9
  from sqlalchemy.schema import ForeignKey
10
10
  from sqlalchemy.orm import relationship
11
- from sqlalchemy.orm import backref
12
11
 
13
12
  from libres.modules.rasterizer import (
14
13
  rasterize_start,
15
14
  rasterize_end,
16
15
  )
17
16
 
18
- from libres.db.models import ORMBase, Allocation
17
+ from libres.db.models.allocation import Allocation
18
+ from libres.db.models.base import ORMBase
19
19
  from libres.db.models.types import UUID, UTCDateTime
20
20
  from libres.db.models.timestamp import TimestampMixin
21
21
 
22
22
 
23
+ from typing import Literal
23
24
  from typing import TYPE_CHECKING
24
25
  if TYPE_CHECKING:
25
26
  import uuid
@@ -52,22 +53,25 @@ class ReservedSlot(TimestampMixin, ORMBase):
52
53
 
53
54
  allocation_id: Column[int] = Column(
54
55
  types.Integer(),
55
- ForeignKey(Allocation.id),
56
+ ForeignKey('allocations.id'),
56
57
  nullable=False
57
58
  )
58
59
 
60
+ # Reserved_slots are eagerly joined since we usually want both
61
+ # allocation and reserved_slots. There's barely a function which does
62
+ # not need to know about reserved slots when working with allocations.
59
63
  allocation: relationship[Allocation] = relationship(
60
64
  Allocation,
61
65
  primaryjoin=Allocation.id == allocation_id,
66
+ back_populates='reserved_slots',
67
+ )
62
68
 
63
- # Reserved_slots are eagerly joined since we usually want both
64
- # allocation and reserved_slots. There's barely a function which does
65
- # not need to know about reserved slots when working with allocation.
66
- backref=backref(
67
- 'reserved_slots',
68
- lazy='joined',
69
- cascade='all, delete-orphan'
70
- )
69
+ source_type: Column[Literal['reservation', 'blocker']] = Column(
70
+ types.Enum( # type:ignore[arg-type]
71
+ 'reservation', 'blocker',
72
+ name='reserved_slot_source_type'
73
+ ),
74
+ nullable=False
71
75
  )
72
76
 
73
77
  reservation_token: Column[uuid.UUID] = Column(
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ from typing import NamedTuple
5
+ from typing import TYPE_CHECKING
6
+ if TYPE_CHECKING:
7
+ from datetime import datetime
8
+
9
+
10
+ class Timespan(NamedTuple):
11
+ start: datetime
12
+ end: datetime
@@ -25,6 +25,7 @@ class JSON(_Base):
25
25
  """
26
26
 
27
27
  impl = JSONB
28
+ cache_ok = True
28
29
 
29
30
  def process_bind_param( # type:ignore[override]
30
31
  self,
libres/db/queries.py CHANGED
@@ -253,7 +253,7 @@ class Queries(ContextServicesMixin):
253
253
 
254
254
  slots = self.session.query(ReservedSlot).filter(
255
255
  ReservedSlot.reservation_token == token
256
- )
256
+ ).filter(ReservedSlot.source_type == 'reservation')
257
257
 
258
258
  slots.delete('fetch')
259
259
 
@@ -340,6 +340,7 @@ class Queries(ContextServicesMixin):
340
340
  reservations.with_entities(Reservation.token)
341
341
  )
342
342
  )
343
+ slots = slots.filter(ReservedSlot.source_type == 'reservation')
343
344
 
344
345
  slots.delete('fetch')
345
346
  reservations.delete('fetch')
libres/db/scheduler.py CHANGED
@@ -10,7 +10,11 @@ from sqlalchemy.sql import and_, not_
10
10
  from uuid import uuid4 as new_uuid, UUID
11
11
 
12
12
  from libres.context.core import ContextServicesMixin
13
- from libres.db.models import ORMBase, Allocation, ReservedSlot, Reservation
13
+ from libres.db.models import ORMBase
14
+ from libres.db.models import Allocation
15
+ from libres.db.models import ReservedSlot
16
+ from libres.db.models import Reservation
17
+ from libres.db.models import ReservationBlocker
14
18
  from libres.db.queries import Queries
15
19
  from libres.modules import errors
16
20
  from libres.modules import events
@@ -195,6 +199,13 @@ class Scheduler(ContextServicesMixin):
195
199
 
196
200
  return query
197
201
 
202
+ def managed_blockers(self) -> Query[ReservationBlocker]:
203
+ """ The blockers managed by this scheduler / resource. """
204
+ query = self.session.query(ReservationBlocker)
205
+ query = query.filter(ReservationBlocker.resource == self.resource)
206
+
207
+ return query
208
+
198
209
  def extinguish_managed_records(self) -> None:
199
210
  """ WARNING:
200
211
  Completely removes any trace of the records managed by this scheduler.
@@ -202,6 +213,7 @@ class Scheduler(ContextServicesMixin):
202
213
 
203
214
  """
204
215
  self.managed_reservations().delete('fetch')
216
+ self.managed_blockers().delete('fetch')
205
217
  self.managed_reserved_slots().delete('fetch')
206
218
  self.managed_allocations().delete('fetch')
207
219
 
@@ -265,17 +277,43 @@ class Scheduler(ContextServicesMixin):
265
277
  if id is not None:
266
278
  groups = groups.filter(Reservation.id == id)
267
279
 
268
- allocations = self.managed_allocations()
269
- allocations = allocations.with_entities(Allocation.id)
270
- allocations = allocations.filter(Allocation.group.in_(groups))
280
+ query: Query[Allocation] = self.managed_allocations()
281
+ query = query.join(ReservedSlot)
282
+ query = query.filter(Allocation.group.in_(groups))
283
+ query = query.filter(
284
+ ReservedSlot.reservation_token == token
285
+ )
286
+ query = query.filter(
287
+ ReservedSlot.source_type == 'reservation'
288
+ )
289
+ return query
290
+
291
+ def allocations_by_blocker(
292
+ self,
293
+ token: UUID,
294
+ id: int | None = None
295
+ ) -> Query[Allocation]:
296
+ """ Returns the allocations for the blocker.
297
+
298
+ If you already have a blocker you can instead use target_allocations.
299
+
300
+ """
301
+
302
+ groups = self.managed_blockers()
303
+ groups = groups.with_entities(ReservationBlocker.target)
304
+ groups = groups.filter(ReservationBlocker.token == token)
305
+
306
+ if id is not None:
307
+ groups = groups.filter(ReservationBlocker.id == id)
271
308
 
272
309
  query: Query[Allocation] = self.managed_allocations()
273
310
  query = query.join(ReservedSlot)
311
+ query = query.filter(Allocation.group.in_(groups))
274
312
  query = query.filter(
275
313
  ReservedSlot.reservation_token == token
276
314
  )
277
315
  query = query.filter(
278
- ReservedSlot.allocation_id.in_(allocations)
316
+ ReservedSlot.source_type == 'blocker'
279
317
  )
280
318
  return query
281
319
 
@@ -957,6 +995,10 @@ class Scheduler(ContextServicesMixin):
957
995
  reservations = self.managed_reservations()
958
996
  reservations = reservations.with_entities(Reservation.target)
959
997
 
998
+ # all the blockers
999
+ blockers = self.managed_blockers()
1000
+ blockers = blockers.with_entities(ReservationBlocker.target)
1001
+
960
1002
  # all the groups which are fully inside the required scope
961
1003
  groups = self.managed_allocations().with_entities(Allocation.group)
962
1004
  groups = groups.group_by(Allocation.group)
@@ -994,6 +1036,11 @@ class Scheduler(ContextServicesMixin):
994
1036
  not_(Allocation.group.in_(reservations))
995
1037
  )
996
1038
 
1039
+ # .. without the ones with blockers
1040
+ candidates = candidates.filter(
1041
+ not_(Allocation.group.in_(blockers))
1042
+ )
1043
+
997
1044
  # .. including only the groups fully inside the required scope
998
1045
  allocations = candidates.filter(Allocation.group.in_(groups))
999
1046
 
@@ -1324,6 +1371,7 @@ class Scheduler(ContextServicesMixin):
1324
1371
  slot.end = slot_end
1325
1372
  slot.resource = allocation.resource
1326
1373
  slot.reservation_token = reservation.token
1374
+ slot.source_type = 'reservation'
1327
1375
 
1328
1376
  # the slots are written with the allocation
1329
1377
  allocation.reserved_slots.append(slot)
@@ -1416,7 +1464,7 @@ class Scheduler(ContextServicesMixin):
1416
1464
 
1417
1465
  def change_email(self, token: UUID, new_email: str) -> None:
1418
1466
 
1419
- for reservation in self.reservations_by_token(token).all():
1467
+ for reservation in self.reservations_by_token(token):
1420
1468
  reservation.email = new_email
1421
1469
 
1422
1470
  def change_reservation_data(
@@ -1425,7 +1473,7 @@ class Scheduler(ContextServicesMixin):
1425
1473
  data: Any | None
1426
1474
  ) -> None:
1427
1475
 
1428
- for reservation in self.reservations_by_token(token).all():
1476
+ for reservation in self.reservations_by_token(token):
1429
1477
  reservation.data = data
1430
1478
 
1431
1479
  def change_reservation_time_candidates(
@@ -1511,7 +1559,7 @@ class Scheduler(ContextServicesMixin):
1511
1559
  if existing_reservation.end in ends:
1512
1560
  return None
1513
1561
 
1514
- # will return raise a MultipleResultsFound exception if this is a group
1562
+ # will raise a MultipleResultsFound exception if this is a group
1515
1563
  if existing_reservation.status == 'approved':
1516
1564
  allocation = self.allocations_by_reservation(token, id).one()
1517
1565
  else:
@@ -1556,6 +1604,298 @@ class Scheduler(ContextServicesMixin):
1556
1604
 
1557
1605
  return new_reservation
1558
1606
 
1607
+ @overload
1608
+ def add_blocker(
1609
+ self,
1610
+ dates: _dtrange | Collection[_dtrange],
1611
+ group: None = ...,
1612
+ reason: str | None = ...,
1613
+ token: UUID | None = ...
1614
+ ) -> list[ReservationBlocker]: ...
1615
+
1616
+ @overload
1617
+ def add_blocker(
1618
+ self,
1619
+ dates: None,
1620
+ group: UUID,
1621
+ reason: str | None = ...,
1622
+ token: UUID | None = ...
1623
+ ) -> list[ReservationBlocker]: ...
1624
+
1625
+ @overload
1626
+ def add_blocker(
1627
+ self,
1628
+ dates: None = ...,
1629
+ *,
1630
+ group: UUID,
1631
+ reason: str | None = ...,
1632
+ token: UUID | None = ...
1633
+ ) -> list[ReservationBlocker]: ...
1634
+
1635
+ def add_blocker(
1636
+ self,
1637
+ dates: _dtrange | Collection[_dtrange] | None = None,
1638
+ group: UUID | None = None,
1639
+ reason: str | None = None,
1640
+ token: UUID | None = None
1641
+ ) -> list[ReservationBlocker]:
1642
+ """ Adds a blocker to one or many allocations.
1643
+
1644
+ Returns a list of `ReservationBlocker`, compared to reservations
1645
+ blockers are added in a single step and don't require approval.
1646
+
1647
+ Blockers are intended for administrative purposes, where adding
1648
+ a reservation would be too cumbersome by comparison.
1649
+
1650
+ :dates:
1651
+ The dates to reserve. May either be a tuple of start/end datetimes
1652
+ or a list of such tuples.
1653
+
1654
+ :group:
1655
+ The allocation group to reserve. ``dates``and ``group`` are
1656
+ mutually exclusive.
1657
+
1658
+ :reason:
1659
+ The reason for blocking the targeted ranges.
1660
+
1661
+ """
1662
+
1663
+ assert (dates or group) and not (dates and group)
1664
+
1665
+ if group:
1666
+ dates = self.allocation_dates_by_group(group)
1667
+
1668
+ assert dates is not None
1669
+ dates = self._prepare_dates(dates)
1670
+ timezone = self.timezone
1671
+
1672
+ # First, the request is checked for saneness. If any requested
1673
+ # date cannot be reserved the request as a whole fails.
1674
+ for start, end in dates:
1675
+
1676
+ # are the parameters valid?
1677
+ if not utils.is_valid_reservation_length(start, end, timezone):
1678
+ raise errors.ReservationTooLong
1679
+
1680
+ if start > end or (end - start).seconds < 5 * 60:
1681
+ raise errors.ReservationTooShort
1682
+
1683
+ # can all allocations be reserved?
1684
+ for allocation in self.allocations_in_range(start, end):
1685
+
1686
+ # start and end are not rasterized, so we need this check
1687
+ if not allocation.overlaps(start, end):
1688
+ continue
1689
+
1690
+ assert allocation.is_master
1691
+
1692
+ if not allocation.find_spot(start, end):
1693
+ raise errors.AlreadyReservedError
1694
+
1695
+ free = self.free_allocations_count(allocation, start, end)
1696
+ if free < allocation.quota:
1697
+ raise errors.AlreadyReservedError
1698
+
1699
+ if not allocation.contains(start, end):
1700
+ raise errors.TimerangeTooLong()
1701
+
1702
+ # ok, we're good to go
1703
+ if token is None:
1704
+ token = new_uuid()
1705
+ reserved_slots = []
1706
+
1707
+ def create_reserved_slots(
1708
+ allocation: Allocation,
1709
+ start: datetime,
1710
+ end: datetime,
1711
+ including_mirrors: bool = True
1712
+ ) -> None:
1713
+ for slot_start, slot_end in allocation.all_slots(start, end):
1714
+ slot = ReservedSlot()
1715
+ slot.start = slot_start
1716
+ slot.end = slot_end
1717
+ slot.resource = allocation.resource
1718
+ slot.reservation_token = token
1719
+ slot.source_type = 'blocker'
1720
+
1721
+ # the slots are written with the allocation
1722
+ allocation.reserved_slots.append(slot)
1723
+ reserved_slots.append(slot)
1724
+
1725
+ # the allocation may be fake, make it real
1726
+ if allocation.is_transient:
1727
+ self.session.add(allocation)
1728
+
1729
+ if not including_mirrors or allocation.quota == 1:
1730
+ return
1731
+
1732
+ for mirror in self.allocation_mirrors_by_master(allocation):
1733
+ create_reserved_slots(
1734
+ mirror, start, end,
1735
+ including_mirrors=False
1736
+ )
1737
+
1738
+ # groups are reserved by group-identifier - so all members of a group
1739
+ # or none of them. As such there's no start / end date which is defined
1740
+ # implicitly by the allocation
1741
+ def new_blockers_by_group(
1742
+ group: UUID | None
1743
+ ) -> Iterator[ReservationBlocker]:
1744
+
1745
+ if group:
1746
+ blocker = ReservationBlocker()
1747
+ blocker.token = token
1748
+ blocker.target = group
1749
+ blocker.target_type = 'group'
1750
+ blocker.resource = self.resource
1751
+ blocker.reason = reason
1752
+
1753
+ for allocation in self.allocations_by_group(group):
1754
+ create_reserved_slots(
1755
+ allocation,
1756
+ allocation._start,
1757
+ allocation._end
1758
+ )
1759
+
1760
+ yield blocker
1761
+
1762
+ # all other reservations are reserved by start/end date
1763
+ def new_blockers_by_dates(
1764
+ dates: list[tuple[datetime, datetime]]
1765
+ ) -> Iterator[ReservationBlocker]:
1766
+
1767
+ already_reserved_groups = set()
1768
+
1769
+ for start, end in dates:
1770
+ for allocation in self.allocations_in_range(start, end):
1771
+ if allocation.group in already_reserved_groups:
1772
+ continue
1773
+
1774
+ if not allocation.overlaps(start, end):
1775
+ continue
1776
+
1777
+ # automatically reserve the whole group if the allocation
1778
+ # is part of a group
1779
+ if allocation.in_group:
1780
+ already_reserved_groups.add(allocation.group)
1781
+
1782
+ yield from new_blockers_by_group(allocation.group)
1783
+ else:
1784
+ blocker = ReservationBlocker()
1785
+ blocker.token = token
1786
+ blocker.start, blocker.end = rasterizer.rasterize_span(
1787
+ start, end, allocation.raster
1788
+ )
1789
+ blocker.timezone = allocation.timezone
1790
+ blocker.target = allocation.group
1791
+ blocker.target_type = 'allocation'
1792
+ blocker.resource = self.resource
1793
+ blocker.reason = reason
1794
+
1795
+ create_reserved_slots(allocation, start, end)
1796
+
1797
+ yield blocker
1798
+
1799
+ # create the blockers and reserved slots
1800
+ if group:
1801
+ blockers = list(new_blockers_by_group(group))
1802
+ else:
1803
+ blockers = list(new_blockers_by_dates(dates))
1804
+
1805
+ if not blockers:
1806
+ raise errors.InvalidReservationError
1807
+
1808
+ if not reserved_slots:
1809
+ raise errors.NotReservableError
1810
+
1811
+ for blocker in blockers:
1812
+ self.session.add(blocker)
1813
+
1814
+ return blockers
1815
+
1816
+ def remove_blocker(
1817
+ self,
1818
+ token: UUID,
1819
+ id: int | None = None
1820
+ ) -> None:
1821
+ """ Removes all reserved slots of the given reservation blocker token.
1822
+
1823
+ The id is optional. If given, only the blocker with the given
1824
+ token AND id is removed.
1825
+
1826
+ """
1827
+
1828
+ for slot in self.reserved_slots_by_blocker(token, id):
1829
+ self.session.delete(slot)
1830
+
1831
+ for blocker in self.blockers_by_token(token, id):
1832
+ self.session.delete(blocker)
1833
+
1834
+ # some allocations still reference reserved_slots if not for this
1835
+ self.session.expire_all()
1836
+
1837
+ def change_blocker_reason(
1838
+ self,
1839
+ token: UUID,
1840
+ new_reason: str | None,
1841
+ ) -> None:
1842
+
1843
+ for blocker in self.blockers_by_token(token):
1844
+ blocker.reason = new_reason
1845
+
1846
+ def change_blocker(
1847
+ self,
1848
+ token: UUID,
1849
+ id: int,
1850
+ new_start: datetime,
1851
+ new_end: datetime,
1852
+ ) -> ReservationBlocker | None:
1853
+ """ Allows to change the timespan of a blocker under certain
1854
+ conditions:
1855
+
1856
+ - The new timespan must be reservable inside the existing allocation.
1857
+ (So you cannot use this method to block another allocation)
1858
+ - The referenced allocation must not be in a group.
1859
+
1860
+ Returns the new blocker if a change was made and None instead.
1861
+
1862
+ """
1863
+
1864
+ assert new_start and new_end
1865
+
1866
+ new_start, new_end = self._prepare_range(new_start, new_end)
1867
+ existing_blocker = self.blockers_by_token(token, id).one()
1868
+
1869
+ # if there's nothing to change, do not change
1870
+ if (
1871
+ existing_blocker.start == new_start
1872
+ and existing_blocker.end in (
1873
+ new_end,
1874
+ new_end - timedelta(microseconds=1)
1875
+ )
1876
+ ):
1877
+ return None
1878
+
1879
+ # will raise a MultipleResultsFound exception if this is a group
1880
+ allocation = existing_blocker.target_allocations().one()
1881
+
1882
+ if not allocation.contains(new_start, new_end):
1883
+ raise errors.TimerangeTooLong()
1884
+
1885
+ old_reason = existing_blocker.reason
1886
+
1887
+ with self.begin_nested():
1888
+ self.remove_blocker(token, id)
1889
+
1890
+ new_blocker, = self.add_blocker(
1891
+ dates=(new_start, new_end),
1892
+ reason=old_reason,
1893
+ token=token
1894
+ )
1895
+ new_blocker.id = id
1896
+
1897
+ return new_blocker
1898
+
1559
1899
  def search_allocations(
1560
1900
  self,
1561
1901
  start: datetime,
@@ -1651,7 +1991,7 @@ class Scheduler(ContextServicesMixin):
1651
1991
  known_groups = set()
1652
1992
  known_ids = set()
1653
1993
 
1654
- for allocation in query.all():
1994
+ for allocation in query:
1655
1995
 
1656
1996
  if not self.is_allocation_exposed(allocation):
1657
1997
  continue
@@ -1788,6 +2128,23 @@ class Scheduler(ContextServicesMixin):
1788
2128
 
1789
2129
  return targets
1790
2130
 
2131
+ def reserved_slots_by_type(
2132
+ self,
2133
+ token: UUID,
2134
+ type: Literal['reservation', 'blocker'] | None = None,
2135
+ ) -> Query[ReservedSlot]:
2136
+ """ Returns all reserved slots for the given token.
2137
+ The type is also optional and can be used to restrict the type of
2138
+ reserved slot that's returned
2139
+ """
2140
+
2141
+ assert token
2142
+
2143
+ query = self.managed_reserved_slots()
2144
+ if type is not None:
2145
+ query = query.filter(ReservedSlot.source_type == type)
2146
+ return query.filter(ReservedSlot.reservation_token == token)
2147
+
1791
2148
  def reserved_slots_by_reservation(
1792
2149
  self,
1793
2150
  token: UUID,
@@ -1798,10 +2155,7 @@ class Scheduler(ContextServicesMixin):
1798
2155
  specific reservation matching token and id.
1799
2156
  """
1800
2157
 
1801
- assert token
1802
-
1803
- query = self.managed_reserved_slots()
1804
- query = query.filter(ReservedSlot.reservation_token == token)
2158
+ query = self.reserved_slots_by_type(token, 'reservation')
1805
2159
 
1806
2160
  if id is None:
1807
2161
  return query
@@ -1810,6 +2164,25 @@ class Scheduler(ContextServicesMixin):
1810
2164
  ids = allocations.with_entities(Allocation.id)
1811
2165
  return query.filter(ReservedSlot.allocation_id.in_(ids))
1812
2166
 
2167
+ def reserved_slots_by_blocker(
2168
+ self,
2169
+ token: UUID,
2170
+ id: int | None = None
2171
+ ) -> Query[ReservedSlot]:
2172
+ """ Returns all reserved slots of the given blocker.
2173
+ The id is optional and may be used only return the slots from a
2174
+ specific reservation matching token and id.
2175
+ """
2176
+
2177
+ query = self.reserved_slots_by_type(token, 'blocker')
2178
+
2179
+ if id is None:
2180
+ return query
2181
+ else:
2182
+ allocations = self.allocations_by_blocker(token, id)
2183
+ ids = allocations.with_entities(Allocation.id)
2184
+ return query.filter(ReservedSlot.allocation_id.in_(ids))
2185
+
1813
2186
  def reservations_by_group(self, group: UUID) -> Query[Reservation]:
1814
2187
  tokens = self.managed_reservations().with_entities(Reservation.token)
1815
2188
  tokens = tokens.filter(Reservation.target == group)
@@ -1844,3 +2217,22 @@ class Scheduler(ContextServicesMixin):
1844
2217
  raise errors.InvalidReservationToken from None
1845
2218
 
1846
2219
  return query
2220
+
2221
+ def blockers_by_token(
2222
+ self,
2223
+ token: UUID,
2224
+ id: int | None = None
2225
+ ) -> Query[ReservationBlocker]:
2226
+
2227
+ query = self.managed_blockers()
2228
+ query = query.filter(ReservationBlocker.token == token)
2229
+
2230
+ if id:
2231
+ query = query.filter(ReservationBlocker.id == id)
2232
+
2233
+ try:
2234
+ query.first()
2235
+ except exc.NoResultFound:
2236
+ raise errors.InvalidReservationToken from None
2237
+
2238
+ return query
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: libres
3
- Version: 0.9.1
3
+ Version: 0.10.1
4
4
  Summary: A library to reserve things
5
5
  Home-page: http://github.com/seantis/libres/
6
6
  Author: Denis Krienbühl
@@ -142,6 +142,55 @@ After this, create a new release on Github.
142
142
  Changelog
143
143
  ---------
144
144
 
145
+ 0.10.1 (21.01.2026)
146
+ ~~~~~~~~~~~~~~~~~~~
147
+
148
+ - Adds proper support for SQLAlchemy 1.4. As a result of this
149
+ `Allocation.type` and `Reservation.type` are no longer nullable
150
+ and have a default value of 'generic', you may use the following
151
+ recipe using an alembic `Operations` object to migrate existing
152
+ databases::
153
+
154
+ context.operations.execute("""
155
+ UPDATE allocations
156
+ SET type = 'generic'
157
+ WHERE type IS NULL;
158
+ """)
159
+ context.operations.alter_column('allocations', 'type', nullable=False)
160
+ context.operations.execute("""
161
+ UPDATE reservations
162
+ SET type = 'generic'
163
+ WHERE type IS NULL;
164
+ """)
165
+ context.operations.alter_column('reservations', 'type', nullable=False)
166
+
167
+ 0.10.0 (15.01.2026)
168
+ ~~~~~~~~~~~~~~~~~~~
169
+
170
+ - Adds new entity `ReservationBlocker` for administrative blockers
171
+ of targeted allocations for the targeted timespans, this also ends
172
+ up adding a new column `source_type` to `ReservedSlot` which can be
173
+ added using the following recipe using an alembic `Operations` object::
174
+
175
+ operations.add_column(
176
+ 'reserved_slots',
177
+ Column(
178
+ 'source_type',
179
+ Enum(
180
+ 'reservation', 'blocker',
181
+ name='reserved_slot_source_type'
182
+ ),
183
+ nullable=False,
184
+ server_default='reservation'
185
+ )
186
+ )
187
+ operations.alter_column(
188
+ 'reserved_slots',
189
+ 'source_type',
190
+ server_default=None
191
+ )
192
+
193
+
145
194
  0.9.1 (05.08.2025)
146
195
  ~~~~~~~~~~~~~~~~~~~
147
196
 
@@ -1,5 +1,5 @@
1
1
  libres/.gitignore,sha256=tyWYoDW7-zMdsfiJIVONRWJ5JmqJCTHzBOsi_rkIYZg,49
2
- libres/__init__.py,sha256=d2iNp33yOxIx7AAvW064M_9oiCtEl9Zxr1WVwK8Vl1U,243
2
+ libres/__init__.py,sha256=-HTpgjL18mN101cTf5qlPy_SacZ67ivw_To5W7fu2Zs,260
3
3
  libres/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  libres/context/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  libres/context/core.py,sha256=AZMmFVqTVSkK3pomhoamLWOeqynY23g_uf32yjhgrRQ,7353
@@ -8,17 +8,19 @@ libres/context/registry.py,sha256=JfV-QccpDbAEq154aHu7_-hznr5kXYY58czZ-0-m8vA,49
8
8
  libres/context/session.py,sha256=bVvZ7cvSKffGrzxpDM7vKrGgwAPWRoS4No6HK1--xlg,2488
9
9
  libres/context/settings.py,sha256=IkdG67QUUuk_sQMXiwsJgelrsbx6M-7f72uJBA2JoJA,1374
10
10
  libres/db/__init__.py,sha256=Fks6T8aEacYVvXB9U1v182zKX7SuEUAkC5hTK50AuQk,145
11
- libres/db/queries.py,sha256=mVLMcFh7tbAOOp8fveB1T-ZBt0X3hQiaN5Krt5Mmhhs,10917
12
- libres/db/scheduler.py,sha256=Pg-bgyrMgCE54K7sXxChNw_NTIdviCZSO7_X0LG0o34,64711
13
- libres/db/models/__init__.py,sha256=IL52K-0tg_v6zuiiE2k4UQNqneeb8CLUUVo_t_OBU-g,325
14
- libres/db/models/allocation.py,sha256=Zi-H3HGz4Xt9VhwUZmoUMc8xGr8rPBNiHGumnYcjlwI,29005
11
+ libres/db/queries.py,sha256=EqsrKL5x5OlTWefvcESs5zg7ZyneXavTC-sGARX4OxM,11043
12
+ libres/db/scheduler.py,sha256=mPuA-48VWQvzHQVy0VsXz4Be_f1YDVlElXWjwGaI6hA,77373
13
+ libres/db/models/__init__.py,sha256=ESy82b2y6YX_D143QP2cns1MRsXxO32fQUvrc7DE6VY,408
14
+ libres/db/models/allocation.py,sha256=BzDE7SDvcP1AH_T_P2g5JmcWop06b2rYIUbno8OtXPY,30717
15
15
  libres/db/models/base.py,sha256=Y2Lc60vJ1Du73D0sLozVNi1TfhtrQ3i_64NV7hsnc4g,117
16
- libres/db/models/other.py,sha256=REg6GlJo58eAubu4MzJqNtWqoEJrq_hTGeOs7XYU5BQ,791
17
- libres/db/models/reservation.py,sha256=GmDf9r_Sr0ZOQG2q-GbOtDTeJkOa8FJSi2tpWYk_hEA,5865
18
- libres/db/models/reserved_slot.py,sha256=_r8RUHlaQHDgo14oO5mPUyfS8pI4jmrP-_l5HzuCPwA,3090
16
+ libres/db/models/blocker.py,sha256=P30FLf-ILqT03ZHnbFBVykY37xFAuCeOglhIH6B_1-c,4488
17
+ libres/db/models/other.py,sha256=XRdyLo1gFY-Sg4iBSPBwOxeqZSpL5PerZISexcAQyDg,853
18
+ libres/db/models/reservation.py,sha256=3hLIgyIAAi1f5HuQekLx1FVyZeVn0jMc6tKm4p8cGKM,5852
19
+ libres/db/models/reserved_slot.py,sha256=nVggmkPBB4oR1f5dEH-bsLggX5hAUNqfH7CApcRoApw,3263
20
+ libres/db/models/timespan.py,sha256=uo6Xjie5rzm8JklsdCp0CyjeUPYOSHO9tOZI3b1oShs,220
19
21
  libres/db/models/timestamp.py,sha256=EsuxdQSUsPCkHb1F7HdrDevWZ5wDWerDFRIhqfhefNw,1301
20
22
  libres/db/models/types/__init__.py,sha256=8fx7ecDasCKsCJxGjt4qP5BbDJCG88dJRa0nzD6IetY,186
21
- libres/db/models/types/json_type.py,sha256=ko6K4YRWERWCpg0wYzuwJ_u-VLJ4K3j3ksJ7cGyiMzw,1137
23
+ libres/db/models/types/json_type.py,sha256=yDRc0RQfVRLGfXH_hKDqPaVUkdy8m9XYrr5PU8GPdeU,1157
22
24
  libres/db/models/types/utcdatetime.py,sha256=4Wlk0Yc6e3PljOjngUGuYvo8jps76RYVkCEwmPygzsY,1285
23
25
  libres/db/models/types/uuid_type.py,sha256=sesgyF65JV0Zh2ty7rSeALpZxKjVg1Y68_Pv7gJAuAo,1675
24
26
  libres/modules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -26,8 +28,8 @@ libres/modules/errors.py,sha256=afcvKDfu7XYnGt6CfTG1gpmCaoMTPgtxwmTe2Cyz9qc,2146
26
28
  libres/modules/events.py,sha256=-YHwFJKory8RSIRTths5LBn92sodEPkhARH_a1Xml3c,5299
27
29
  libres/modules/rasterizer.py,sha256=-KYG03YkaPNnyohl_HrEJZqdHrhmGesbLgxxPahNTCc,3155
28
30
  libres/modules/utils.py,sha256=W5Kuu9LhhUWZMKLAhw7DmQEuNYb9aXWpW_HXCn62lhU,1871
29
- libres-0.9.1.dist-info/licenses/LICENSE,sha256=w1rojULT3naueSnr4r62MSQipL4VPtsfEcTFmSKpVuI,1069
30
- libres-0.9.1.dist-info/METADATA,sha256=e-7a5dHP6OCJ5ob5wsH1-Wk4pr0qVMMZDU3wtzd4Y98,10222
31
- libres-0.9.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
32
- libres-0.9.1.dist-info/top_level.txt,sha256=Exs6AnhZc0UTcsD5Ylyx1P89j19hJ4Dy13jxQyZwi3k,7
33
- libres-0.9.1.dist-info/RECORD,,
31
+ libres-0.10.1.dist-info/licenses/LICENSE,sha256=w1rojULT3naueSnr4r62MSQipL4VPtsfEcTFmSKpVuI,1069
32
+ libres-0.10.1.dist-info/METADATA,sha256=TB23WZed7MiH2ShovWJKHXq1bFjnpXwq2RWeMZhpwBc,11646
33
+ libres-0.10.1.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
34
+ libres-0.10.1.dist-info/top_level.txt,sha256=Exs6AnhZc0UTcsD5Ylyx1P89j19hJ4Dy13jxQyZwi3k,7
35
+ libres-0.10.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5