5 Commits

Author SHA1 Message Date
0e016c9ec2 debian package 0.2-1 2023-10-07 12:19:02 +02:00
f5e21bfaf2 Merge tag 'v0.2' into debian
pqm version 0.2
2023-10-07 12:11:10 +02:00
1f98e6627d add __slots__ to most classes 2023-10-02 09:18:32 +02:00
b8d9c6f2a1 fix json stream decode performance issue
string slicing is expensive as python copies the (immutable) string...
2023-10-02 09:18:25 +02:00
ec3ec224f7 debian package 0.1-1 2023-01-12 10:57:06 +01:00
11 changed files with 138 additions and 9 deletions

19
debian/changelog vendored Normal file
View File

@ -0,0 +1,19 @@
pqm (0.2-1) unstable; urgency=medium
* fix json stream decode performance issue
* add __slots__ to most classes
* require python >= 3.10
-- Stefan Bühler <stefan.buehler@tik.uni-stuttgart.de> Sat, 07 Oct 2023 12:11:28 +0200
pqm (0.1-1) unstable; urgency=medium
* Initial release.
-- Stefan Bühler <stefan.buehler@tik.uni-stuttgart.de> Thu, 12 Jan 2023 10:14:06 +0100
pqm (0.1-0) unstable; urgency=medium
* Initial release (fake ITP).
-- Stefan Bühler <stefan.buehler@tik.uni-stuttgart.de> Thu, 12 Jan 2023 10:14:05 +0100

21
debian/control vendored Normal file
View File

@ -0,0 +1,21 @@
Source: pqm
Section: mail
Priority: optional
Maintainer: Stefan Bühler <stefan.buehler@tik.uni-stuttgart.de>
Rules-Requires-Root: no
Build-Depends:
debhelper-compat (= 13),
Standards-Version: 4.6.1
Homepage: https://git-nks-public.tik.uni-stuttgart.de/mail/pqm
Package: pqm
Architecture: all
Depends:
python3:any (>= 3.10),
python3-trio,
python3-pyparsing,
python3-yaml,
${misc:Depends},
Description: postfix (cluster) queue manager
CLI tool to manage postfix queues (optionally across multiple hosts through
ssh).

28
debian/copyright vendored Normal file
View File

@ -0,0 +1,28 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Source: https://git-nks-public.tik.uni-stuttgart.de/mail/pqm
Upstream-Name: pqm
Files:
*
Copyright:
2023 Universität Stuttgart <no-support-by-mail@uni-stuttgart.de>
License: MIT
License: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

5
debian/gbp.conf vendored Normal file
View File

@ -0,0 +1,5 @@
[DEFAULT]
debian-branch = debian
upstream-branch = main
upstream-tag = v%(version)s
pristine-tar = False

2
debian/pqm.docs vendored Normal file
View File

@ -0,0 +1,2 @@
README.md
pqm.example.yaml

1
debian/pqm.install vendored Normal file
View File

@ -0,0 +1 @@
pqm /usr/bin

1
debian/pqm.lintian-overrides vendored Normal file
View File

@ -0,0 +1 @@
pqm: no-manual-page [usr/bin/pqm]

4
debian/rules vendored Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/make -f
%:
dh $@

1
debian/source/format vendored Normal file
View File

@ -0,0 +1 @@
3.0 (quilt)

1
debian/source/options vendored Normal file
View File

@ -0,0 +1 @@
extend-diff-ignore = "(^|/)(venv|.mypy_cache)(/|$)"

64
pqm
View File

@ -33,7 +33,7 @@ import yaml
T = typing.TypeVar('T') T = typing.TypeVar('T')
@dataclasses.dataclass @dataclasses.dataclass(slots=True)
class Config: class Config:
# if not remote_sources are configured -> use local queue # if not remote_sources are configured -> use local queue
remote_sources: dict[str, str] = dataclasses.field(default_factory=dict) remote_sources: dict[str, str] = dataclasses.field(default_factory=dict)
@ -157,7 +157,7 @@ async def trio_parallel_ordered(
await tpo.close() await tpo.close()
@dataclasses.dataclass @dataclasses.dataclass(slots=True)
class Recipient: class Recipient:
"""Recipient in a postfix mail""" """Recipient in a postfix mail"""
address: str address: str
@ -184,7 +184,22 @@ class QueueName(enum.Enum):
ALL_QUEUE_NAMES: set[QueueName] = set(QueueName) ALL_QUEUE_NAMES: set[QueueName] = set(QueueName)
@dataclasses.dataclass def json_decode_stream(data: str):
decoder = json.JSONDecoder()
data_len = len(data)
data_pos = 0
while data_len > data_pos and data[data_len-1].isspace():
data_len -= 1
while True:
while data_pos < data_len and data[data_pos].isspace():
data_pos += 1
if data_pos >= data_len:
return
obj, data_pos = decoder.raw_decode(data, data_pos)
yield obj
@dataclasses.dataclass(slots=True)
class Mail: class Mail:
"""Metadata for mail in postfix queue""" """Metadata for mail in postfix queue"""
queue_name: QueueName queue_name: QueueName
@ -209,11 +224,7 @@ class Mail:
@staticmethod @staticmethod
def read_postqueue_json(data: str, id_prefix: str = '') -> list[Mail]: def read_postqueue_json(data: str, id_prefix: str = '') -> list[Mail]:
queue = [] queue = []
decoder = json.JSONDecoder() for obj in json_decode_stream(data):
data = data.strip()
while data:
obj, end = decoder.raw_decode(data, 0)
data = data[end:].lstrip()
mail = Mail.from_json(obj) mail = Mail.from_json(obj)
mail.queue_id = id_prefix + mail.queue_id mail.queue_id = id_prefix + mail.queue_id
queue.append(mail) queue.append(mail)
@ -222,6 +233,8 @@ class Mail:
# abstract collection/cluster of postfix nodes (or just a single one) # abstract collection/cluster of postfix nodes (or just a single one)
class Source(abc.ABC): class Source(abc.ABC):
__slots__ = ()
# list of server names in collection # list of server names in collection
@abc.abstractmethod @abc.abstractmethod
def server_list(self) -> list[str]: def server_list(self) -> list[str]:
@ -320,6 +333,8 @@ class Source(abc.ABC):
class LocalSource(Source): class LocalSource(Source):
__slots__ = ()
def server_list(self) -> list[str]: def server_list(self) -> list[str]:
return [socket.gethostname()] return [socket.gethostname()]
@ -377,6 +392,8 @@ class LocalSource(Source):
class RemoteSource(Source): class RemoteSource(Source):
__slots__ = ('remotes',)
def __init__(self, remotes: dict[str, str]) -> None: def __init__(self, remotes: dict[str, str]) -> None:
super().__init__() super().__init__()
self.remotes = remotes self.remotes = remotes
@ -509,6 +526,8 @@ class RemoteSource(Source):
class UserActivityStats: class UserActivityStats:
__slots__ = ('count_mails', 'related_mails', 'cut_off')
# find most active address for `health` command # find most active address for `health` command
def __init__(self, cut_off: int = 10) -> None: def __init__(self, cut_off: int = 10) -> None:
@ -577,6 +596,8 @@ class UserActivityStats:
class Filter(abc.ABC): class Filter(abc.ABC):
"""abstract base class for filter expressions""" """abstract base class for filter expressions"""
__slots__ = ()
def __init__(self) -> None: def __init__(self) -> None:
pass pass
@ -639,11 +660,14 @@ class Filter(abc.ABC):
# abstract base class for filters that don't need (...) around in representations of members in combined filters # abstract base class for filters that don't need (...) around in representations of members in combined filters
class SingleExprFilter(Filter): class SingleExprFilter(Filter):
pass __slots__ = ()
class FalseFilter(SingleExprFilter): class FalseFilter(SingleExprFilter):
"""constant false - matches no mail""" """constant false - matches no mail"""
__slots__ = ()
def __repr__(self) -> str: def __repr__(self) -> str:
return '0' return '0'
@ -669,6 +693,9 @@ class FalseFilter(SingleExprFilter):
class TrueFilter(SingleExprFilter): class TrueFilter(SingleExprFilter):
"""constant true - matches all mail""" """constant true - matches all mail"""
__slots__ = ()
def __repr__(self) -> str: def __repr__(self) -> str:
return '1' return '1'
@ -697,6 +724,8 @@ TRUE_FILTER = TrueFilter()
class AndFilter(Filter): class AndFilter(Filter):
__slots__ = ('expressions',)
def __init__(self, expressions: list[Filter] | tuple[()] = ()) -> None: def __init__(self, expressions: list[Filter] | tuple[()] = ()) -> None:
super().__init__() super().__init__()
self.expressions: list[Filter] = expressions or [] self.expressions: list[Filter] = expressions or []
@ -750,6 +779,8 @@ class AndFilter(Filter):
class OrFilter(Filter): class OrFilter(Filter):
__slots__ = ('expressions',)
def __init__(self, expressions: list[Filter] | tuple[()] = ()) -> None: def __init__(self, expressions: list[Filter] | tuple[()] = ()) -> None:
super().__init__() super().__init__()
self.expressions: list[Filter] = expressions or [] self.expressions: list[Filter] = expressions or []
@ -803,6 +834,8 @@ class OrFilter(Filter):
class NotFilter(SingleExprFilter): class NotFilter(SingleExprFilter):
__slots__ = ('expression',)
def __init__(self, expression: Filter) -> None: def __init__(self, expression: Filter) -> None:
super().__init__() super().__init__()
self.expression = expression self.expression = expression
@ -840,6 +873,9 @@ class NotFilter(SingleExprFilter):
class QueueFilter(SingleExprFilter): class QueueFilter(SingleExprFilter):
"""match mails based on queue they are in""" """match mails based on queue they are in"""
__slots__ = ('select',)
def __init__(self, select: list[QueueName]) -> None: def __init__(self, select: list[QueueName]) -> None:
self.select = set(select) self.select = set(select)
@ -898,6 +934,8 @@ class AddressSelector(enum.Enum):
class BaseAddressPattern(abc.ABC): class BaseAddressPattern(abc.ABC):
__slots__ = ()
# abstract base class of patterns to use to match a mail address # abstract base class of patterns to use to match a mail address
# subclasses match either: full address, domain part, regex # subclasses match either: full address, domain part, regex
def __init__(self) -> None: def __init__(self) -> None:
@ -930,6 +968,8 @@ class BaseAddressPattern(abc.ABC):
class AddressPattern(BaseAddressPattern): class AddressPattern(BaseAddressPattern):
__slots__ = ('address',)
# match full address exactly # match full address exactly
def __init__(self, address: str) -> None: def __init__(self, address: str) -> None:
super().__init__() super().__init__()
@ -952,6 +992,8 @@ class AddressPattern(BaseAddressPattern):
class AddressDomainPattern(BaseAddressPattern): class AddressDomainPattern(BaseAddressPattern):
__slots__ = ('domain',)
# match address by domain # match address by domain
def __init__(self, domain: str) -> None: def __init__(self, domain: str) -> None:
super().__init__() super().__init__()
@ -969,6 +1011,8 @@ class AddressDomainPattern(BaseAddressPattern):
class AddressRegexMatch(BaseAddressPattern): class AddressRegexMatch(BaseAddressPattern):
__slots__ = ('address',)
# match address by regex # match address by regex
def __init__(self, address: str | re.Pattern[str]) -> None: def __init__(self, address: str | re.Pattern[str]) -> None:
super().__init__() super().__init__()
@ -990,6 +1034,8 @@ class AddressRegexMatch(BaseAddressPattern):
class AddressFilter(SingleExprFilter): class AddressFilter(SingleExprFilter):
__slots__ = ('selector', 'patterns')
# match mails by address # match mails by address
def __init__(self, selector: AddressSelector, patterns: list[BaseAddressPattern]) -> None: def __init__(self, selector: AddressSelector, patterns: list[BaseAddressPattern]) -> None:
self.selector = selector self.selector = selector