libres 0.8.0__py3-none-any.whl → 0.9.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
@@ -5,7 +5,7 @@ from libres.db import new_scheduler
5
5
 
6
6
  registry = create_default_registry()
7
7
 
8
- __version__ = '0.8.0'
8
+ __version__ = '0.9.1'
9
9
  __all__ = (
10
10
  'new_scheduler',
11
11
  'registry'
libres/context/core.py CHANGED
@@ -61,15 +61,15 @@ class ContextServicesMixin:
61
61
 
62
62
  @cached_property
63
63
  def is_allocation_exposed(self) -> Callable[[Allocation], bool]:
64
- return self.context.get_service('exposure').is_allocation_exposed
64
+ return self.context.get_service('exposure').is_allocation_exposed # type: ignore[no-any-return]
65
65
 
66
66
  @cached_property
67
67
  def generate_uuid(self) -> Callable[[str], UUID]:
68
- return self.context.get_service('uuid_generator')
68
+ return self.context.get_service('uuid_generator') # type: ignore[no-any-return]
69
69
 
70
70
  @cached_property
71
71
  def validate_email(self) -> Callable[[str], bool]:
72
- return self.context.get_service('email_validator')
72
+ return self.context.get_service('email_validator') # type: ignore[no-any-return]
73
73
 
74
74
  def clear_cache(self) -> None:
75
75
  """ Clears the cache of the mixin. """
@@ -91,12 +91,12 @@ class ContextServicesMixin:
91
91
 
92
92
  @property
93
93
  def session_provider(self) -> SessionProvider:
94
- return self.context.get_service('session_provider')
94
+ return self.context.get_service('session_provider') # type: ignore[no-any-return]
95
95
 
96
96
  @property
97
97
  def session(self) -> Session:
98
98
  """ Returns the current session. """
99
- return self.session_provider.session()
99
+ return self.session_provider.session() # type: ignore[no-any-return]
100
100
 
101
101
  def close(self) -> None:
102
102
  """ Closes the current session. """
@@ -82,6 +82,8 @@ class Registry:
82
82
  """
83
83
 
84
84
  contexts: dict[str, Context]
85
+ # FIXME: Why do we allow this to be None? Do we make use
86
+ # of this anywhere?
85
87
  master_context: Context | None = None
86
88
 
87
89
  def __init__(self) -> None:
@@ -98,7 +100,7 @@ class Registry:
98
100
  if not hasattr(self.local, 'current_context'):
99
101
  self.local.current_context = self.master_context
100
102
 
101
- return self.local.current_context
103
+ return self.local.current_context # type: ignore[no-any-return]
102
104
 
103
105
  def is_existing_context(self, name: str) -> bool:
104
106
  return name in self.contexts
@@ -34,14 +34,18 @@ if TYPE_CHECKING:
34
34
  from collections.abc import Iterator
35
35
  from sedate.types import TzInfoOrName
36
36
  from sqlalchemy.orm import Query
37
+ from typing import NamedTuple
37
38
  from typing_extensions import Self
38
39
 
39
- from libres.db.models import Reservation, ReservedSlot
40
+ from libres.db.models import ReservedSlot
40
41
  from libres.modules.rasterizer import Raster
41
42
 
42
43
  _OptDT1 = TypeVar('_OptDT1', 'datetime | None', datetime, None)
43
44
  _OptDT2 = TypeVar('_OptDT2', 'datetime | None', datetime, None)
44
45
 
46
+ class _ReservationIdRow(NamedTuple):
47
+ id: int
48
+
45
49
 
46
50
  class Allocation(TimestampMixin, ORMBase, OtherModels):
47
51
  """Describes a timespan within which one or many timeslots can be
@@ -114,7 +118,7 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
114
118
  timezone: Column[str | None] = Column(types.String())
115
119
 
116
120
  #: Custom data reserved for the user
117
- data: Column[Any | None] = Column(
121
+ data: Column[dict[str, Any] | None] = Column(
118
122
  JSON(),
119
123
  nullable=True
120
124
  )
@@ -470,7 +474,7 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
470
474
  return self.display_start(timezone), self.display_end(timezone)
471
475
 
472
476
  @property
473
- def pending_reservations(self) -> Query[Reservation]:
477
+ def pending_reservations(self) -> Query[_ReservationIdRow]:
474
478
  """ Returns the pending reservations query for this allocation.
475
479
  As the pending reservations target the group and not a specific
476
480
  allocation this function returns the same value for masters and
@@ -482,6 +486,7 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
482
486
  )
483
487
 
484
488
  Reservation = self.models.Reservation # noqa: N806
489
+ query: Query[_ReservationIdRow]
485
490
  query = object_session(self).query(Reservation.id)
486
491
  query = query.filter(Reservation.target == self.group)
487
492
  query = query.filter(Reservation.status == 'pending')
@@ -794,7 +799,9 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
794
799
  if self.is_master:
795
800
  return self
796
801
  else:
797
- query = object_session(self).query(Allocation)
802
+ # FIXME: This should either query `self.__class__` or
803
+ # we need to return `Allocation` rather than `Self`
804
+ query: Query[Self] = object_session(self).query(Allocation)
798
805
  query = query.filter(Allocation._start == self._start)
799
806
  query = query.filter(Allocation.resource == self.mirror_of)
800
807
 
@@ -818,7 +825,9 @@ class Allocation(TimestampMixin, ORMBase, OtherModels):
818
825
  assert self.is_master
819
826
  return [self]
820
827
 
821
- query = object_session(self).query(Allocation)
828
+ # FIXME: This should either query `self.__class__` or
829
+ # we need to return `Allocation` rather than `Self`
830
+ query: Query[Self] = object_session(self).query(Allocation)
822
831
  query = query.filter(Allocation.mirror_of == self.mirror_of)
823
832
  query = query.filter(Allocation._start == self._start)
824
833
 
@@ -96,7 +96,7 @@ class Reservation(TimestampMixin, ORMBase, OtherModels):
96
96
  nullable=False
97
97
  )
98
98
 
99
- data: Column[Any | None] = deferred(
99
+ data: Column[dict[str, Any] | None] = deferred(
100
100
  Column(
101
101
  JSON(),
102
102
  nullable=True
@@ -148,7 +148,7 @@ class Reservation(TimestampMixin, ORMBase, OtherModels):
148
148
  # order by date
149
149
  query = query.order_by(Allocation._start)
150
150
 
151
- return query
151
+ return query # type: ignore[no-any-return]
152
152
 
153
153
  def display_start(
154
154
  self,
@@ -212,4 +212,4 @@ class Reservation(TimestampMixin, ORMBase, OtherModels):
212
212
  # A reservation is deemed autoapprovable if no allocation
213
213
  # requires explicit approval
214
214
 
215
- return object_session(self).query(~query.exists()).scalar()
215
+ return object_session(self).query(~query.exists()).scalar() # type: ignore[no-any-return]
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from json import loads, dumps
4
- from sqlalchemy.types import TypeDecorator, TEXT
3
+ from sqlalchemy.ext.mutable import MutableDict
4
+ from sqlalchemy.types import TypeDecorator
5
+ from sqlalchemy.dialects.postgresql import JSONB
5
6
 
6
7
 
7
8
  from typing import Any
@@ -9,43 +10,37 @@ from typing import TYPE_CHECKING
9
10
  if TYPE_CHECKING:
10
11
  from sqlalchemy.engine import Dialect
11
12
 
12
- _Base = TypeDecorator[Any]
13
+ _Base = TypeDecorator[dict[str, Any]]
13
14
  else:
14
15
  _Base = TypeDecorator
15
16
 
16
17
 
17
18
  class JSON(_Base):
18
- """Like the default JSON, but using the json serializer from the dialect
19
- (postgres) each time the value is read, even if it never left the ORM. The
20
- default json type will only do it when the record is read from the
21
- database.
19
+ """ A JSONB based type that coerces None's to empty dictionaries.
20
+
21
+ That is, this JSONB column cannot be `'null'::jsonb`. It could
22
+ still be `NULL` though, if it's nullable and never explicitly
23
+ set. But on the Python end you should always see a dictionary.
22
24
 
23
25
  """
24
26
 
25
- # Use TEXT for now to stay compatible with Postgres 9.1. In the future
26
- # this will be replaced by JSON (or JSONB) though that requires that we
27
- # require a later Postgres release. For now we stay backwards compatible
28
- # with a version that's still widely used (9.1).
29
- impl = TEXT
27
+ impl = JSONB
30
28
 
31
- def process_bind_param(
29
+ def process_bind_param( # type:ignore[override]
32
30
  self,
33
- value: Any,
31
+ value: dict[str, Any] | None,
34
32
  dialect: Dialect
35
- ) -> str | None:
36
-
37
- if value is not None:
38
- value = (dialect._json_serializer or dumps)(value) # type:ignore
33
+ ) -> dict[str, Any]:
39
34
 
40
- return value
35
+ return {} if value is None else value
41
36
 
42
37
  def process_result_value(
43
38
  self,
44
- value: str | None,
39
+ value: dict[str, Any] | None,
45
40
  dialect: Dialect
46
- ) -> Any | None:
41
+ ) -> dict[str, Any]:
42
+
43
+ return {} if value is None else value
47
44
 
48
- if value is not None:
49
- value = (dialect._json_deserializer or loads)(value) # type:ignore
50
45
 
51
- return value
46
+ MutableDict.associate_with(JSON) # type:ignore[no-untyped-call]
libres/db/scheduler.py CHANGED
@@ -269,7 +269,7 @@ class Scheduler(ContextServicesMixin):
269
269
  allocations = allocations.with_entities(Allocation.id)
270
270
  allocations = allocations.filter(Allocation.group.in_(groups))
271
271
 
272
- query = self.managed_allocations()
272
+ query: Query[Allocation] = self.managed_allocations()
273
273
  query = query.join(ReservedSlot)
274
274
  query = query.filter(
275
275
  ReservedSlot.reservation_token == token
@@ -306,7 +306,10 @@ class Scheduler(ContextServicesMixin):
306
306
  ) -> list[tuple[datetime, datetime]]:
307
307
 
308
308
  query = self.allocations_by_group(group)
309
- dates_query = query.with_entities(Allocation._start, Allocation._end)
309
+ dates_query: Query[tuple[datetime, datetime]] = query.with_entities(
310
+ Allocation._start,
311
+ Allocation._end
312
+ )
310
313
  return dates_query.all()
311
314
 
312
315
  def allocation_mirrors_by_master(
@@ -336,7 +339,7 @@ class Scheduler(ContextServicesMixin):
336
339
  query = self.allocations_by_ids(ids)
337
340
  query = query.filter(Allocation.approve_manually == True)
338
341
 
339
- return self.session.query(query.exists()).scalar()
342
+ return self.session.query(query.exists()).scalar() # type: ignore[no-any-return]
340
343
 
341
344
  def allocate(
342
345
  self,
@@ -1273,8 +1276,9 @@ class Scheduler(ContextServicesMixin):
1273
1276
  # reservation twice on a single session
1274
1277
  if session_id:
1275
1278
  found = self.queries.reservations_by_session(session_id)
1276
- found = found.with_entities(Reservation.target, Reservation.start)
1277
- found_set = set(found)
1279
+ found_set: set[tuple[UUID, datetime | None]] = set(
1280
+ found.with_entities(Reservation.target, Reservation.start)
1281
+ )
1278
1282
 
1279
1283
  for reservation in reservations:
1280
1284
  if (reservation.target, reservation.start) in found_set:
@@ -1625,8 +1629,6 @@ class Scheduler(ContextServicesMixin):
1625
1629
  assert start
1626
1630
  assert end
1627
1631
 
1628
- start, end = self._prepare_range(start, end)
1629
-
1630
1632
  assert whole_day in ('yes', 'no', 'any')
1631
1633
  assert groups in ('yes', 'no', 'any')
1632
1634
 
@@ -1641,7 +1643,7 @@ class Scheduler(ContextServicesMixin):
1641
1643
  else:
1642
1644
  day_numbers = None
1643
1645
 
1644
- query = self.allocations_in_range(start, end)
1646
+ query = self.allocations_in_range(*self._prepare_range(start, end))
1645
1647
  query = query.order_by(Allocation._start)
1646
1648
 
1647
1649
  allocations = []
@@ -1653,13 +1655,19 @@ class Scheduler(ContextServicesMixin):
1653
1655
 
1654
1656
  if not self.is_allocation_exposed(allocation):
1655
1657
  continue
1656
-
1657
- s = datetime.combine(allocation.start.date(), start.time())
1658
- e = datetime.combine(allocation.end.date(), end.time())
1659
-
1660
- # the raw dates will be UTC
1661
- s = sedate.replace_timezone(s, 'UTC')
1662
- e = sedate.replace_timezone(e, 'UTC')
1658
+ allocation_start = allocation.display_start()
1659
+ # NOTE: We want the correct timezone, but we don't want the
1660
+ # date to be on the next day for a full-day reservation
1661
+ # so we skip the microsecond addition
1662
+ allocation_end = sedate.to_timezone(allocation.end, self.timezone)
1663
+ s = sedate.standardize_date(datetime.combine(
1664
+ allocation_start.date(),
1665
+ start.time()
1666
+ ), self.timezone)
1667
+ e = sedate.standardize_date(datetime.combine(
1668
+ allocation_end.date(),
1669
+ end.time()
1670
+ ), self.timezone)
1663
1671
 
1664
1672
  if not allocation.overlaps(s, e):
1665
1673
  continue
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: libres
3
- Version: 0.8.0
3
+ Version: 0.9.1
4
4
  Summary: A library to reserve things
5
5
  Home-page: http://github.com/seantis/libres/
6
6
  Author: Denis Krienbühl
@@ -51,13 +51,14 @@ Requires-Dist: types-psycopg2; extra == "mypy"
51
51
  Requires-Dist: types-python-dateutil; extra == "mypy"
52
52
  Requires-Dist: types-pytz; extra == "mypy"
53
53
  Requires-Dist: typing-extensions; extra == "mypy"
54
+ Dynamic: license-file
54
55
 
55
56
  Libres
56
57
  ======
57
58
 
58
59
  Libres is a reservations management library to reserve things like tables at
59
- a restaurant or tickets at an event. It works with Python 3.8+
60
- and requires Postgresql 9.1+.
60
+ a restaurant or tickets at an event. It works with Python 3.9+
61
+ and requires Postgresql 9.2+.
61
62
 
62
63
  `Documentation <http://libres.readthedocs.org/en/latest/>`_ | `Source <http://github.com/seantis/libres/>`_ | `Bugs <http://github.com/seantis/libres/issues>`_
63
64
 
@@ -141,6 +142,28 @@ After this, create a new release on Github.
141
142
  Changelog
142
143
  ---------
143
144
 
145
+ 0.9.1 (05.08.2025)
146
+ ~~~~~~~~~~~~~~~~~~~
147
+
148
+ - Fixes bug in `Scheduler.search_allocations` when the searched
149
+ time range contains multiple DST <-> ST transitions.
150
+ [Daverball]
151
+
152
+ 0.9.0 (23.05.2025)
153
+ ~~~~~~~~~~~~~~~~~~~
154
+
155
+ - Replaces `JSON` database type with `JSONB`, this means
156
+ Postgres as a backend is not required. You will also need
157
+ to write a migration for existing JSON columns. You may use
158
+ the following recipe using an alembic `Operations` object::
159
+
160
+ operations.alter_column(
161
+ 'table_name',
162
+ 'column_name',
163
+ type_=JSON,
164
+ postgresql_using='"column_name"::jsonb'
165
+ )
166
+
144
167
  0.8.0 (15.01.2025)
145
168
  ~~~~~~~~~~~~~~~~~~~
146
169
 
@@ -1,24 +1,24 @@
1
1
  libres/.gitignore,sha256=tyWYoDW7-zMdsfiJIVONRWJ5JmqJCTHzBOsi_rkIYZg,49
2
- libres/__init__.py,sha256=2QrrbMI8BVca59DEOOBgREHXCcaj7cVNT4lvHfmsRnA,243
2
+ libres/__init__.py,sha256=d2iNp33yOxIx7AAvW064M_9oiCtEl9Zxr1WVwK8Vl1U,243
3
3
  libres/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  libres/context/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- libres/context/core.py,sha256=a-T7hCQPjLdCBYNk3PXaBJ41Kp8-TDPYSSgJjbEj9VY,7198
5
+ libres/context/core.py,sha256=AZMmFVqTVSkK3pomhoamLWOeqynY23g_uf32yjhgrRQ,7353
6
6
  libres/context/exposure.py,sha256=0krsky3XYplG7Uf5oLlQgdXp6XuEDjN2JGe3ljgLM14,287
7
- libres/context/registry.py,sha256=Dhgp-0mv_YRKoXOS7kgJR9I71VRdlGmpeC6fPHARa-k,4863
7
+ libres/context/registry.py,sha256=JfV-QccpDbAEq154aHu7_-hznr5kXYY58czZ-0-m8vA,4986
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
11
  libres/db/queries.py,sha256=mVLMcFh7tbAOOp8fveB1T-ZBt0X3hQiaN5Krt5Mmhhs,10917
12
- libres/db/scheduler.py,sha256=urxmtrAppwqtZG34TKeV33GWkf8FtPoR7vmirkNJ0uc,64217
12
+ libres/db/scheduler.py,sha256=Pg-bgyrMgCE54K7sXxChNw_NTIdviCZSO7_X0LG0o34,64711
13
13
  libres/db/models/__init__.py,sha256=IL52K-0tg_v6zuiiE2k4UQNqneeb8CLUUVo_t_OBU-g,325
14
- libres/db/models/allocation.py,sha256=Myv0uSvaZobEp2cJ4HJNrhHi44dgir9zowj8eBv6GXg,28577
14
+ libres/db/models/allocation.py,sha256=Zi-H3HGz4Xt9VhwUZmoUMc8xGr8rPBNiHGumnYcjlwI,29005
15
15
  libres/db/models/base.py,sha256=Y2Lc60vJ1Du73D0sLozVNi1TfhtrQ3i_64NV7hsnc4g,117
16
16
  libres/db/models/other.py,sha256=REg6GlJo58eAubu4MzJqNtWqoEJrq_hTGeOs7XYU5BQ,791
17
- libres/db/models/reservation.py,sha256=kPumvHkskqOOKrO-nT4W7dNGbyK4T6USd42TbFWy4j4,5792
17
+ libres/db/models/reservation.py,sha256=GmDf9r_Sr0ZOQG2q-GbOtDTeJkOa8FJSi2tpWYk_hEA,5865
18
18
  libres/db/models/reserved_slot.py,sha256=_r8RUHlaQHDgo14oO5mPUyfS8pI4jmrP-_l5HzuCPwA,3090
19
19
  libres/db/models/timestamp.py,sha256=EsuxdQSUsPCkHb1F7HdrDevWZ5wDWerDFRIhqfhefNw,1301
20
20
  libres/db/models/types/__init__.py,sha256=8fx7ecDasCKsCJxGjt4qP5BbDJCG88dJRa0nzD6IetY,186
21
- libres/db/models/types/json_type.py,sha256=cvSYlTmAcZRBHgyd86Bd3TlB3pDeBBODudUuO_s8gMQ,1357
21
+ libres/db/models/types/json_type.py,sha256=ko6K4YRWERWCpg0wYzuwJ_u-VLJ4K3j3ksJ7cGyiMzw,1137
22
22
  libres/db/models/types/utcdatetime.py,sha256=4Wlk0Yc6e3PljOjngUGuYvo8jps76RYVkCEwmPygzsY,1285
23
23
  libres/db/models/types/uuid_type.py,sha256=sesgyF65JV0Zh2ty7rSeALpZxKjVg1Y68_Pv7gJAuAo,1675
24
24
  libres/modules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -26,8 +26,8 @@ libres/modules/errors.py,sha256=afcvKDfu7XYnGt6CfTG1gpmCaoMTPgtxwmTe2Cyz9qc,2146
26
26
  libres/modules/events.py,sha256=-YHwFJKory8RSIRTths5LBn92sodEPkhARH_a1Xml3c,5299
27
27
  libres/modules/rasterizer.py,sha256=-KYG03YkaPNnyohl_HrEJZqdHrhmGesbLgxxPahNTCc,3155
28
28
  libres/modules/utils.py,sha256=W5Kuu9LhhUWZMKLAhw7DmQEuNYb9aXWpW_HXCn62lhU,1871
29
- libres-0.8.0.dist-info/LICENSE,sha256=w1rojULT3naueSnr4r62MSQipL4VPtsfEcTFmSKpVuI,1069
30
- libres-0.8.0.dist-info/METADATA,sha256=35WiUS7fsECvLwm6KhlDutJS5l7EAdM3swvB7QPzLug,9603
31
- libres-0.8.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
32
- libres-0.8.0.dist-info/top_level.txt,sha256=Exs6AnhZc0UTcsD5Ylyx1P89j19hJ4Dy13jxQyZwi3k,7
33
- libres-0.8.0.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5