diff options
Diffstat (limited to 'src/mailman/model')
27 files changed, 571 insertions, 533 deletions
diff --git a/src/mailman/model/address.py b/src/mailman/model/address.py index f69679210..5d1994567 100644 --- a/src/mailman/model/address.py +++ b/src/mailman/model/address.py @@ -26,7 +26,8 @@ __all__ = [ from email.utils import formataddr -from storm.locals import DateTime, Int, Reference, Unicode +from sqlalchemy import Column, DateTime, ForeignKey, Integer, Unicode +from sqlalchemy.orm import relationship, backref from zope.component import getUtility from zope.event import notify from zope.interface import implementer @@ -42,17 +43,20 @@ from mailman.utilities.datetime import now class Address(Model): """See `IAddress`.""" - id = Int(primary=True) - email = Unicode() - _original = Unicode() - display_name = Unicode() - _verified_on = DateTime(name='verified_on') - registered_on = DateTime() + __tablename__ = 'address' - user_id = Int() - user = Reference(user_id, 'User.id') - preferences_id = Int() - preferences = Reference(preferences_id, 'Preferences.id') + id = Column(Integer, primary_key=True) + email = Column(Unicode) + _original = Column(Unicode) + display_name = Column(Unicode) + _verified_on = Column('verified_on', DateTime) + registered_on = Column(DateTime) + + user_id = Column(Integer, ForeignKey('user.id'), index=True) + + preferences_id = Column(Integer, ForeignKey('preferences.id'), index=True) + preferences = relationship( + 'Preferences', backref=backref('address', uselist=False)) def __init__(self, email, display_name): super(Address, self).__init__() diff --git a/src/mailman/model/autorespond.py b/src/mailman/model/autorespond.py index c5e736613..cfb9e017d 100644 --- a/src/mailman/model/autorespond.py +++ b/src/mailman/model/autorespond.py @@ -26,7 +26,8 @@ __all__ = [ ] -from storm.locals import And, Date, Desc, Int, Reference +from sqlalchemy import Column, Date, ForeignKey, Integer, desc +from sqlalchemy.orm import relationship from zope.interface import implementer from mailman.database.model import Model @@ -42,16 +43,18 @@ from mailman.utilities.datetime import today class AutoResponseRecord(Model): """See `IAutoResponseRecord`.""" - id = Int(primary=True) + __tablename__ = 'autoresponserecord' - address_id = Int() - address = Reference(address_id, 'Address.id') + id = Column(Integer, primary_key=True) - mailing_list_id = Int() - mailing_list = Reference(mailing_list_id, 'MailingList.id') + address_id = Column(Integer, ForeignKey('address.id'), index=True) + address = relationship('Address') - response_type = Enum(Response) - date_sent = Date() + mailing_list_id = Column(Integer, ForeignKey('mailinglist.id'), index=True) + mailing_list = relationship('MailingList') + + response_type = Column(Enum(Response)) + date_sent = Column(Date) def __init__(self, mailing_list, address, response_type): self.mailing_list = mailing_list @@ -71,12 +74,11 @@ class AutoResponseSet: @dbconnection def todays_count(self, store, address, response_type): """See `IAutoResponseSet`.""" - return store.find( - AutoResponseRecord, - And(AutoResponseRecord.address == address, - AutoResponseRecord.mailing_list == self._mailing_list, - AutoResponseRecord.response_type == response_type, - AutoResponseRecord.date_sent == today())).count() + return store.query(AutoResponseRecord).filter_by( + address=address, + mailing_list=self._mailing_list, + response_type=response_type, + date_sent=today()).count() @dbconnection def response_sent(self, store, address, response_type): @@ -88,10 +90,9 @@ class AutoResponseSet: @dbconnection def last_response(self, store, address, response_type): """See `IAutoResponseSet`.""" - results = store.find( - AutoResponseRecord, - And(AutoResponseRecord.address == address, - AutoResponseRecord.mailing_list == self._mailing_list, - AutoResponseRecord.response_type == response_type) - ).order_by(Desc(AutoResponseRecord.date_sent)) + results = store.query(AutoResponseRecord).filter_by( + address=address, + mailing_list=self._mailing_list, + response_type=response_type + ).order_by(desc(AutoResponseRecord.date_sent)) return (None if results.count() == 0 else results.first()) diff --git a/src/mailman/model/bans.py b/src/mailman/model/bans.py index 673e8e0c1..8678fc1e7 100644 --- a/src/mailman/model/bans.py +++ b/src/mailman/model/bans.py @@ -27,7 +27,7 @@ __all__ = [ import re -from storm.locals import Int, Unicode +from sqlalchemy import Column, Integer, Unicode from zope.interface import implementer from mailman.database.model import Model @@ -40,9 +40,11 @@ from mailman.interfaces.bans import IBan, IBanManager class Ban(Model): """See `IBan`.""" - id = Int(primary=True) - email = Unicode() - list_id = Unicode() + __tablename__ = 'ban' + + id = Column(Integer, primary_key=True) + email = Column(Unicode) + list_id = Column(Unicode) def __init__(self, email, list_id): super(Ban, self).__init__() @@ -62,7 +64,7 @@ class BanManager: @dbconnection def ban(self, store, email): """See `IBanManager`.""" - bans = store.find(Ban, email=email, list_id=self._list_id) + bans = store.query(Ban).filter_by(email=email, list_id=self._list_id) if bans.count() == 0: ban = Ban(email, self._list_id) store.add(ban) @@ -70,9 +72,10 @@ class BanManager: @dbconnection def unban(self, store, email): """See `IBanManager`.""" - ban = store.find(Ban, email=email, list_id=self._list_id).one() + ban = store.query(Ban).filter_by( + email=email, list_id=self._list_id).first() if ban is not None: - store.remove(ban) + store.delete(ban) @dbconnection def is_banned(self, store, email): @@ -81,32 +84,32 @@ class BanManager: if list_id is None: # The client is asking for global bans. Look up bans on the # specific email address first. - bans = store.find(Ban, email=email, list_id=None) + bans = store.query(Ban).filter_by(email=email, list_id=None) if bans.count() > 0: return True # And now look for global pattern bans. - bans = store.find(Ban, list_id=None) + bans = store.query(Ban).filter_by(list_id=None) for ban in bans: if (ban.email.startswith('^') and re.match(ban.email, email, re.IGNORECASE) is not None): return True else: # This is a list-specific ban. - bans = store.find(Ban, email=email, list_id=list_id) + bans = store.query(Ban).filter_by(email=email, list_id=list_id) if bans.count() > 0: return True # Try global bans next. - bans = store.find(Ban, email=email, list_id=None) + bans = store.query(Ban).filter_by(email=email, list_id=None) if bans.count() > 0: return True # Now try specific mailing list bans, but with a pattern. - bans = store.find(Ban, list_id=list_id) + bans = store.query(Ban).filter_by(list_id=list_id) for ban in bans: if (ban.email.startswith('^') and re.match(ban.email, email, re.IGNORECASE) is not None): return True # And now try global pattern bans. - bans = store.find(Ban, list_id=None) + bans = store.query(Ban).filter_by(list_id=None) for ban in bans: if (ban.email.startswith('^') and re.match(ban.email, email, re.IGNORECASE) is not None): diff --git a/src/mailman/model/bounce.py b/src/mailman/model/bounce.py index 134c51263..cd658052d 100644 --- a/src/mailman/model/bounce.py +++ b/src/mailman/model/bounce.py @@ -26,7 +26,8 @@ __all__ = [ ] -from storm.locals import Bool, Int, DateTime, Unicode + +from sqlalchemy import Boolean, Column, DateTime, Integer, Unicode from zope.interface import implementer from mailman.database.model import Model @@ -42,13 +43,15 @@ from mailman.utilities.datetime import now class BounceEvent(Model): """See `IBounceEvent`.""" - id = Int(primary=True) - list_id = Unicode() - email = Unicode() - timestamp = DateTime() - message_id = Unicode() - context = Enum(BounceContext) - processed = Bool() + __tablename__ = 'bounceevent' + + id = Column(Integer, primary_key=True) + list_id = Column(Unicode) + email = Column(Unicode) + timestamp = Column(DateTime) + message_id = Column(Unicode) + context = Column(Enum(BounceContext)) + processed = Column(Boolean) def __init__(self, list_id, email, msg, context=None): self.list_id = list_id @@ -75,12 +78,12 @@ class BounceProcessor: @dbconnection def events(self, store): """See `IBounceProcessor`.""" - for event in store.find(BounceEvent): + for event in store.query(BounceEvent).all(): yield event @property @dbconnection def unprocessed(self, store): """See `IBounceProcessor`.""" - for event in store.find(BounceEvent, BounceEvent.processed == False): + for event in store.query(BounceEvent).filter_by(processed=False): yield event diff --git a/src/mailman/model/digests.py b/src/mailman/model/digests.py index 5d9f3ddd1..7bfd512b6 100644 --- a/src/mailman/model/digests.py +++ b/src/mailman/model/digests.py @@ -25,7 +25,8 @@ __all__ = [ ] -from storm.locals import Int, Reference +from sqlalchemy import Column, Integer, ForeignKey +from sqlalchemy.orm import relationship from zope.interface import implementer from mailman.database.model import Model @@ -39,15 +40,17 @@ from mailman.interfaces.member import DeliveryMode class OneLastDigest(Model): """See `IOneLastDigest`.""" - id = Int(primary=True) + __tablename__ = 'onelastdigest' - mailing_list_id = Int() - mailing_list = Reference(mailing_list_id, 'MailingList.id') + id = Column(Integer, primary_key=True) - address_id = Int() - address = Reference(address_id, 'Address.id') + mailing_list_id = Column(Integer, ForeignKey('mailinglist.id')) + mailing_list = relationship('MailingList') - delivery_mode = Enum(DeliveryMode) + address_id = Column(Integer, ForeignKey('address.id')) + address = relationship('Address') + + delivery_mode = Column(Enum(DeliveryMode)) def __init__(self, mailing_list, address, delivery_mode): self.mailing_list = mailing_list diff --git a/src/mailman/model/docs/autorespond.rst b/src/mailman/model/docs/autorespond.rst index 6210e48cb..809de934a 100644 --- a/src/mailman/model/docs/autorespond.rst +++ b/src/mailman/model/docs/autorespond.rst @@ -37,34 +37,34 @@ have already been sent today. ... 'aperson@example.com') >>> from mailman.interfaces.autorespond import Response - >>> response_set.todays_count(address, Response.hold) + >>> print(response_set.todays_count(address, Response.hold)) 0 - >>> response_set.todays_count(address, Response.command) + >>> print(response_set.todays_count(address, Response.command)) 0 Using the response set, we can record that a hold response is sent to the address. >>> response_set.response_sent(address, Response.hold) - >>> response_set.todays_count(address, Response.hold) + >>> print(response_set.todays_count(address, Response.hold)) 1 - >>> response_set.todays_count(address, Response.command) + >>> print(response_set.todays_count(address, Response.command)) 0 We can also record that a command response was sent. >>> response_set.response_sent(address, Response.command) - >>> response_set.todays_count(address, Response.hold) + >>> print(response_set.todays_count(address, Response.hold)) 1 - >>> response_set.todays_count(address, Response.command) + >>> print(response_set.todays_count(address, Response.command)) 1 Let's send one more. >>> response_set.response_sent(address, Response.command) - >>> response_set.todays_count(address, Response.hold) + >>> print(response_set.todays_count(address, Response.hold)) 1 - >>> response_set.todays_count(address, Response.command) + >>> print(response_set.todays_count(address, Response.command)) 2 Now the day flips over and all the counts reset. @@ -73,9 +73,9 @@ Now the day flips over and all the counts reset. >>> from mailman.utilities.datetime import factory >>> factory.fast_forward() - >>> response_set.todays_count(address, Response.hold) + >>> print(response_set.todays_count(address, Response.hold)) 0 - >>> response_set.todays_count(address, Response.command) + >>> print(response_set.todays_count(address, Response.command)) 0 @@ -110,7 +110,7 @@ If there's been no response sent to a particular address, None is returned. >>> address = getUtility(IUserManager).create_address( ... 'bperson@example.com') - >>> response_set.todays_count(address, Response.command) + >>> print(response_set.todays_count(address, Response.command)) 0 >>> print(response_set.last_response(address, Response.command)) None diff --git a/src/mailman/model/docs/mailinglist.rst b/src/mailman/model/docs/mailinglist.rst index 53ba99575..3d01710c5 100644 --- a/src/mailman/model/docs/mailinglist.rst +++ b/src/mailman/model/docs/mailinglist.rst @@ -50,7 +50,10 @@ receive a copy of any message sent to the mailing list. Both addresses appear on the roster of members. - >>> for member in mlist.members.members: + >>> from operator import attrgetter + >>> sort_key = attrgetter('address.email') + + >>> for member in sorted(mlist.members.members, key=sort_key): ... print(member) <Member: aperson@example.com on aardvark@example.com as MemberRole.member> <Member: bperson@example.com on aardvark@example.com as MemberRole.member> @@ -72,7 +75,7 @@ A Person is now both a member and an owner of the mailing list. C Person is an owner and a moderator. :: - >>> for member in mlist.owners.members: + >>> for member in sorted(mlist.owners.members, key=sort_key): ... print(member) <Member: aperson@example.com on aardvark@example.com as MemberRole.owner> <Member: cperson@example.com on aardvark@example.com as MemberRole.owner> @@ -87,13 +90,13 @@ All rosters can also be accessed indirectly. :: >>> roster = mlist.get_roster(MemberRole.member) - >>> for member in roster.members: + >>> for member in sorted(roster.members, key=sort_key): ... print(member) <Member: aperson@example.com on aardvark@example.com as MemberRole.member> <Member: bperson@example.com on aardvark@example.com as MemberRole.member> >>> roster = mlist.get_roster(MemberRole.owner) - >>> for member in roster.members: + >>> for member in sorted(roster.members, key=sort_key): ... print(member) <Member: aperson@example.com on aardvark@example.com as MemberRole.owner> <Member: cperson@example.com on aardvark@example.com as MemberRole.owner> @@ -122,7 +125,7 @@ just by changing their preferred address. >>> mlist.subscribe(user) <Member: Dave Person <dperson@example.com> on aardvark@example.com as MemberRole.member> - >>> for member in mlist.members.members: + >>> for member in sorted(mlist.members.members, key=sort_key): ... print(member) <Member: aperson@example.com on aardvark@example.com as MemberRole.member> <Member: bperson@example.com on aardvark@example.com as MemberRole.member> @@ -133,7 +136,7 @@ just by changing their preferred address. >>> new_address.verified_on = now() >>> user.preferred_address = new_address - >>> for member in mlist.members.members: + >>> for member in sorted(mlist.members.members, key=sort_key): ... print(member) <Member: aperson@example.com on aardvark@example.com as MemberRole.member> <Member: bperson@example.com on aardvark@example.com as MemberRole.member> diff --git a/src/mailman/model/docs/messagestore.rst b/src/mailman/model/docs/messagestore.rst index 4ddce7606..f2f2ca9d2 100644 --- a/src/mailman/model/docs/messagestore.rst +++ b/src/mailman/model/docs/messagestore.rst @@ -28,8 +28,9 @@ header, you will get an exception. However, if the message has a ``Message-ID`` header, it can be stored. >>> msg['Message-ID'] = '<87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp>' - >>> message_store.add(msg) - 'AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35' + >>> x_message_id_hash = message_store.add(msg) + >>> print(x_message_id_hash) + AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35 >>> print(msg.as_string()) Subject: An important message Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp> diff --git a/src/mailman/model/docs/requests.rst b/src/mailman/model/docs/requests.rst index e99cef634..1e1eba35a 100644 --- a/src/mailman/model/docs/requests.rst +++ b/src/mailman/model/docs/requests.rst @@ -35,7 +35,7 @@ Holding requests The list's requests database starts out empty. - >>> requests.count + >>> print(requests.count) 0 >>> dump_list(requests.held_requests) *Empty* @@ -68,21 +68,21 @@ Getting requests We can see the total number of requests being held. - >>> requests.count + >>> print(requests.count) 3 We can also see the number of requests being held by request type. - >>> requests.count_of(RequestType.subscription) + >>> print(requests.count_of(RequestType.subscription)) 1 - >>> requests.count_of(RequestType.unsubscription) + >>> print(requests.count_of(RequestType.unsubscription)) 1 We can also see when there are multiple held requests of a particular type. - >>> requests.hold_request(RequestType.held_message, 'hold_4') + >>> print(requests.hold_request(RequestType.held_message, 'hold_4')) 4 - >>> requests.count_of(RequestType.held_message) + >>> print(requests.count_of(RequestType.held_message)) 2 We can ask the requests database for a specific request, by providing the id @@ -132,7 +132,7 @@ Iterating over requests To make it easier to find specific requests, the list requests can be iterated over by type. - >>> requests.count_of(RequestType.held_message) + >>> print(requests.count_of(RequestType.held_message)) 3 >>> for request in requests.of_type(RequestType.held_message): ... key, data = requests.get_request(request.id) @@ -154,10 +154,10 @@ Deleting requests Once a specific request has been handled, it can be deleted from the requests database. - >>> requests.count + >>> print(requests.count) 5 >>> requests.delete_request(2) - >>> requests.count + >>> print(requests.count) 4 Request 2 is no longer in the database. @@ -167,5 +167,5 @@ Request 2 is no longer in the database. >>> for request in requests.held_requests: ... requests.delete_request(request.id) - >>> requests.count + >>> print(requests.count) 0 diff --git a/src/mailman/model/domain.py b/src/mailman/model/domain.py index 28e346022..8290cb755 100644 --- a/src/mailman/model/domain.py +++ b/src/mailman/model/domain.py @@ -26,8 +26,8 @@ __all__ = [ ] +from sqlalchemy import Column, Integer, Unicode from urlparse import urljoin, urlparse -from storm.locals import Int, Unicode from zope.event import notify from zope.interface import implementer @@ -44,12 +44,14 @@ from mailman.model.mailinglist import MailingList class Domain(Model): """Domains.""" - id = Int(primary=True) + __tablename__ = 'domain' - mail_host = Unicode() - base_url = Unicode() - description = Unicode() - contact_address = Unicode() + id = Column(Integer, primary_key=True) + + mail_host = Column(Unicode) # TODO: add index? + base_url = Column(Unicode) + description = Column(Unicode) + contact_address = Column(Unicode) def __init__(self, mail_host, description=None, @@ -92,9 +94,9 @@ class Domain(Model): @dbconnection def mailing_lists(self, store): """See `IDomain`.""" - mailing_lists = store.find( - MailingList, - MailingList.mail_host == self.mail_host) + mailing_lists = store.query(MailingList).filter( + MailingList.mail_host == self.mail_host + ).order_by(MailingList._list_id) for mlist in mailing_lists: yield mlist @@ -140,14 +142,14 @@ class DomainManager: def remove(self, store, mail_host): domain = self[mail_host] notify(DomainDeletingEvent(domain)) - store.remove(domain) + store.delete(domain) notify(DomainDeletedEvent(mail_host)) return domain @dbconnection def get(self, store, mail_host, default=None): """See `IDomainManager`.""" - domains = store.find(Domain, mail_host=mail_host) + domains = store.query(Domain).filter_by(mail_host=mail_host) if domains.count() < 1: return default assert domains.count() == 1, ( @@ -164,15 +166,15 @@ class DomainManager: @dbconnection def __len__(self, store): - return store.find(Domain).count() + return store.query(Domain).count() @dbconnection def __iter__(self, store): """See `IDomainManager`.""" - for domain in store.find(Domain): + for domain in store.query(Domain).order_by(Domain.mail_host).all(): yield domain @dbconnection def __contains__(self, store, mail_host): """See `IDomainManager`.""" - return store.find(Domain, mail_host=mail_host).count() > 0 + return store.query(Domain).filter_by(mail_host=mail_host).count() > 0 diff --git a/src/mailman/model/language.py b/src/mailman/model/language.py index 14cf53f07..f4d48fc97 100644 --- a/src/mailman/model/language.py +++ b/src/mailman/model/language.py @@ -25,11 +25,11 @@ __all__ = [ ] -from storm.locals import Int, Unicode +from sqlalchemy import Column, Integer, Unicode from zope.interface import implementer -from mailman.database import Model -from mailman.interfaces import ILanguage +from mailman.database.model import Model +from mailman.interfaces.languages import ILanguage @@ -37,5 +37,7 @@ from mailman.interfaces import ILanguage class Language(Model): """See `ILanguage`.""" - id = Int(primary=True) - code = Unicode() + __tablename__ = 'language' + + id = Column(Integer, primary_key=True) + code = Column(Unicode) diff --git a/src/mailman/model/listmanager.py b/src/mailman/model/listmanager.py index d648a5bde..261490a92 100644 --- a/src/mailman/model/listmanager.py +++ b/src/mailman/model/listmanager.py @@ -52,9 +52,7 @@ class ListManager: raise InvalidEmailAddressError(fqdn_listname) list_id = '{0}.{1}'.format(listname, hostname) notify(ListCreatingEvent(fqdn_listname)) - mlist = store.find( - MailingList, - MailingList._list_id == list_id).one() + mlist = store.query(MailingList).filter_by(_list_id=list_id).first() if mlist: raise ListAlreadyExistsError(fqdn_listname) mlist = MailingList(fqdn_listname) @@ -68,40 +66,41 @@ class ListManager: """See `IListManager`.""" listname, at, hostname = fqdn_listname.partition('@') list_id = '{0}.{1}'.format(listname, hostname) - return store.find(MailingList, MailingList._list_id == list_id).one() + return store.query(MailingList).filter_by(_list_id=list_id).first() @dbconnection def get_by_list_id(self, store, list_id): """See `IListManager`.""" - return store.find(MailingList, MailingList._list_id == list_id).one() + return store.query(MailingList).filter_by(_list_id=list_id).first() @dbconnection def delete(self, store, mlist): """See `IListManager`.""" fqdn_listname = mlist.fqdn_listname notify(ListDeletingEvent(mlist)) - store.find(ContentFilter, ContentFilter.mailing_list == mlist).remove() - store.remove(mlist) + store.query(ContentFilter).filter_by(mailing_list=mlist).delete() + store.delete(mlist) notify(ListDeletedEvent(fqdn_listname)) @property @dbconnection def mailing_lists(self, store): """See `IListManager`.""" - for mlist in store.find(MailingList): + for mlist in store.query(MailingList).order_by( + MailingList._list_id).all(): yield mlist @dbconnection def __iter__(self, store): """See `IListManager`.""" - for mlist in store.find(MailingList): + for mlist in store.query(MailingList).all(): yield mlist @property @dbconnection def names(self, store): """See `IListManager`.""" - result_set = store.find(MailingList) + result_set = store.query(MailingList) for mail_host, list_name in result_set.values(MailingList.mail_host, MailingList.list_name): yield '{0}@{1}'.format(list_name, mail_host) @@ -110,15 +109,16 @@ class ListManager: @dbconnection def list_ids(self, store): """See `IListManager`.""" - result_set = store.find(MailingList) + result_set = store.query(MailingList) for list_id in result_set.values(MailingList._list_id): - yield list_id + assert isinstance(list_id, tuple) and len(list_id) == 1 + yield list_id[0] @property @dbconnection def name_components(self, store): """See `IListManager`.""" - result_set = store.find(MailingList) + result_set = store.query(MailingList) for mail_host, list_name in result_set.values(MailingList.mail_host, MailingList.list_name): yield list_name, mail_host diff --git a/src/mailman/model/mailinglist.py b/src/mailman/model/mailinglist.py index 955a76968..761a78b94 100644 --- a/src/mailman/model/mailinglist.py +++ b/src/mailman/model/mailinglist.py @@ -27,9 +27,11 @@ __all__ = [ import os -from storm.locals import ( - And, Bool, DateTime, Float, Int, Pickle, RawStr, Reference, Store, - TimeDelta, Unicode) +from sqlalchemy import ( + Boolean, Column, DateTime, Float, ForeignKey, Integer, Interval, + LargeBinary, PickleType, Unicode) +from sqlalchemy.event import listen +from sqlalchemy.orm import relationship from urlparse import urljoin from zope.component import getUtility from zope.event import notify @@ -37,6 +39,7 @@ from zope.interface import implementer from mailman.config import config from mailman.database.model import Model +from mailman.database.transaction import dbconnection from mailman.database.types import Enum from mailman.interfaces.action import Action, FilterAction from mailman.interfaces.address import IAddress @@ -73,121 +76,121 @@ UNDERSCORE = '_' class MailingList(Model): """See `IMailingList`.""" - id = Int(primary=True) + __tablename__ = 'mailinglist' + + id = Column(Integer, primary_key=True) # XXX denotes attributes that should be part of the public interface but # are currently missing. # List identity - list_name = Unicode() - mail_host = Unicode() - _list_id = Unicode(name='list_id') - allow_list_posts = Bool() - include_rfc2369_headers = Bool() - advertised = Bool() - anonymous_list = Bool() + list_name = Column(Unicode) + mail_host = Column(Unicode) + _list_id = Column('list_id', Unicode) + allow_list_posts = Column(Boolean) + include_rfc2369_headers = Column(Boolean) + advertised = Column(Boolean) + anonymous_list = Column(Boolean) # Attributes not directly modifiable via the web u/i - created_at = DateTime() + created_at = Column(DateTime) # Attributes which are directly modifiable via the web u/i. The more # complicated attributes are currently stored as pickles, though that # will change as the schema and implementation is developed. - next_request_id = Int() - next_digest_number = Int() - digest_last_sent_at = DateTime() - volume = Int() - last_post_at = DateTime() - # Implicit destination. - acceptable_aliases_id = Int() - acceptable_alias = Reference(acceptable_aliases_id, 'AcceptableAlias.id') + next_request_id = Column(Integer) + next_digest_number = Column(Integer) + digest_last_sent_at = Column(DateTime) + volume = Column(Integer) + last_post_at = Column(DateTime) # Attributes which are directly modifiable via the web u/i. The more # complicated attributes are currently stored as pickles, though that # will change as the schema and implementation is developed. - accept_these_nonmembers = Pickle() # XXX - admin_immed_notify = Bool() - admin_notify_mchanges = Bool() - administrivia = Bool() - archive_policy = Enum(ArchivePolicy) + accept_these_nonmembers = Column(PickleType) # XXX + admin_immed_notify = Column(Boolean) + admin_notify_mchanges = Column(Boolean) + administrivia = Column(Boolean) + archive_policy = Column(Enum(ArchivePolicy)) # Automatic responses. - autoresponse_grace_period = TimeDelta() - autorespond_owner = Enum(ResponseAction) - autoresponse_owner_text = Unicode() - autorespond_postings = Enum(ResponseAction) - autoresponse_postings_text = Unicode() - autorespond_requests = Enum(ResponseAction) - autoresponse_request_text = Unicode() + autoresponse_grace_period = Column(Interval) + autorespond_owner = Column(Enum(ResponseAction)) + autoresponse_owner_text = Column(Unicode) + autorespond_postings = Column(Enum(ResponseAction)) + autoresponse_postings_text = Column(Unicode) + autorespond_requests = Column(Enum(ResponseAction)) + autoresponse_request_text = Column(Unicode) # Content filters. - filter_action = Enum(FilterAction) - filter_content = Bool() - collapse_alternatives = Bool() - convert_html_to_plaintext = Bool() + filter_action = Column(Enum(FilterAction)) + filter_content = Column(Boolean) + collapse_alternatives = Column(Boolean) + convert_html_to_plaintext = Column(Boolean) # Bounces. - bounce_info_stale_after = TimeDelta() # XXX - bounce_matching_headers = Unicode() # XXX - bounce_notify_owner_on_disable = Bool() # XXX - bounce_notify_owner_on_removal = Bool() # XXX - bounce_score_threshold = Int() # XXX - bounce_you_are_disabled_warnings = Int() # XXX - bounce_you_are_disabled_warnings_interval = TimeDelta() # XXX - forward_unrecognized_bounces_to = Enum(UnrecognizedBounceDisposition) - process_bounces = Bool() + bounce_info_stale_after = Column(Interval) # XXX + bounce_matching_headers = Column(Unicode) # XXX + bounce_notify_owner_on_disable = Column(Boolean) # XXX + bounce_notify_owner_on_removal = Column(Boolean) # XXX + bounce_score_threshold = Column(Integer) # XXX + bounce_you_are_disabled_warnings = Column(Integer) # XXX + bounce_you_are_disabled_warnings_interval = Column(Interval) # XXX + forward_unrecognized_bounces_to = Column( + Enum(UnrecognizedBounceDisposition)) + process_bounces = Column(Boolean) # Miscellaneous - default_member_action = Enum(Action) - default_nonmember_action = Enum(Action) - description = Unicode() - digest_footer_uri = Unicode() - digest_header_uri = Unicode() - digest_is_default = Bool() - digest_send_periodic = Bool() - digest_size_threshold = Float() - digest_volume_frequency = Enum(DigestFrequency) - digestable = Bool() - discard_these_nonmembers = Pickle() - emergency = Bool() - encode_ascii_prefixes = Bool() - first_strip_reply_to = Bool() - footer_uri = Unicode() - forward_auto_discards = Bool() - gateway_to_mail = Bool() - gateway_to_news = Bool() - goodbye_message_uri = Unicode() - header_matches = Pickle() - header_uri = Unicode() - hold_these_nonmembers = Pickle() - info = Unicode() - linked_newsgroup = Unicode() - max_days_to_hold = Int() - max_message_size = Int() - max_num_recipients = Int() - member_moderation_notice = Unicode() - mime_is_default_digest = Bool() + default_member_action = Column(Enum(Action)) + default_nonmember_action = Column(Enum(Action)) + description = Column(Unicode) + digest_footer_uri = Column(Unicode) + digest_header_uri = Column(Unicode) + digest_is_default = Column(Boolean) + digest_send_periodic = Column(Boolean) + digest_size_threshold = Column(Float) + digest_volume_frequency = Column(Enum(DigestFrequency)) + digestable = Column(Boolean) + discard_these_nonmembers = Column(PickleType) + emergency = Column(Boolean) + encode_ascii_prefixes = Column(Boolean) + first_strip_reply_to = Column(Boolean) + footer_uri = Column(Unicode) + forward_auto_discards = Column(Boolean) + gateway_to_mail = Column(Boolean) + gateway_to_news = Column(Boolean) + goodbye_message_uri = Column(Unicode) + header_matches = Column(PickleType) + header_uri = Column(Unicode) + hold_these_nonmembers = Column(PickleType) + info = Column(Unicode) + linked_newsgroup = Column(Unicode) + max_days_to_hold = Column(Integer) + max_message_size = Column(Integer) + max_num_recipients = Column(Integer) + member_moderation_notice = Column(Unicode) + mime_is_default_digest = Column(Boolean) # FIXME: There should be no moderator_password - moderator_password = RawStr() - newsgroup_moderation = Enum(NewsgroupModeration) - nntp_prefix_subject_too = Bool() - nondigestable = Bool() - nonmember_rejection_notice = Unicode() - obscure_addresses = Bool() - owner_chain = Unicode() - owner_pipeline = Unicode() - personalize = Enum(Personalization) - post_id = Int() - posting_chain = Unicode() - posting_pipeline = Unicode() - _preferred_language = Unicode(name='preferred_language') - display_name = Unicode() - reject_these_nonmembers = Pickle() - reply_goes_to_list = Enum(ReplyToMunging) - reply_to_address = Unicode() - require_explicit_destination = Bool() - respond_to_post_requests = Bool() - scrub_nondigest = Bool() - send_goodbye_message = Bool() - send_welcome_message = Bool() - subject_prefix = Unicode() - topics = Pickle() - topics_bodylines_limit = Int() - topics_enabled = Bool() - welcome_message_uri = Unicode() + moderator_password = Column(LargeBinary) # TODO : was RawStr() + newsgroup_moderation = Column(Enum(NewsgroupModeration)) + nntp_prefix_subject_too = Column(Boolean) + nondigestable = Column(Boolean) + nonmember_rejection_notice = Column(Unicode) + obscure_addresses = Column(Boolean) + owner_chain = Column(Unicode) + owner_pipeline = Column(Unicode) + personalize = Column(Enum(Personalization)) + post_id = Column(Integer) + posting_chain = Column(Unicode) + posting_pipeline = Column(Unicode) + _preferred_language = Column('preferred_language', Unicode) + display_name = Column(Unicode) + reject_these_nonmembers = Column(PickleType) + reply_goes_to_list = Column(Enum(ReplyToMunging)) + reply_to_address = Column(Unicode) + require_explicit_destination = Column(Boolean) + respond_to_post_requests = Column(Boolean) + scrub_nondigest = Column(Boolean) + send_goodbye_message = Column(Boolean) + send_welcome_message = Column(Boolean) + subject_prefix = Column(Unicode) + topics = Column(PickleType) + topics_bodylines_limit = Column(Integer) + topics_enabled = Column(Boolean) + welcome_message_uri = Column(Unicode) def __init__(self, fqdn_listname): super(MailingList, self).__init__() @@ -198,14 +201,15 @@ class MailingList(Model): self._list_id = '{0}.{1}'.format(listname, hostname) # For the pending database self.next_request_id = 1 - # We need to set up the rosters. Normally, this method will get - # called when the MailingList object is loaded from the database, but - # that's not the case when the constructor is called. So, set up the - # rosters explicitly. - self.__storm_loaded__() + # We need to set up the rosters. Normally, this method will get called + # when the MailingList object is loaded from the database, but when the + # constructor is called, SQLAlchemy's `load` event isn't triggered. + # Thus we need to set up the rosters explicitly. + self._post_load() makedirs(self.data_path) - def __storm_loaded__(self): + def _post_load(self, *args): + # This hooks up to SQLAlchemy's `load` event. self.owners = roster.OwnerRoster(self) self.moderators = roster.ModeratorRoster(self) self.administrators = roster.AdministratorRoster(self) @@ -215,6 +219,13 @@ class MailingList(Model): self.subscribers = roster.Subscribers(self) self.nonmembers = roster.NonmemberRoster(self) + @classmethod + def __declare_last__(cls): + # SQLAlchemy special directive hook called after mappings are assumed + # to be complete. Use this to connect the roster instance creation + # method with the SA `load` event. + listen(cls, 'load', cls._post_load) + def __repr__(self): return '<mailing list "{0}" at {1:#x}>'.format( self.fqdn_listname, id(self)) @@ -323,42 +334,42 @@ class MailingList(Model): except AttributeError: self._preferred_language = language - def send_one_last_digest_to(self, address, delivery_mode): + @dbconnection + def send_one_last_digest_to(self, store, address, delivery_mode): """See `IMailingList`.""" digest = OneLastDigest(self, address, delivery_mode) - Store.of(self).add(digest) + store.add(digest) @property - def last_digest_recipients(self): + @dbconnection + def last_digest_recipients(self, store): """See `IMailingList`.""" - results = Store.of(self).find( - OneLastDigest, + results = store.query(OneLastDigest).filter( OneLastDigest.mailing_list == self) recipients = [(digest.address, digest.delivery_mode) for digest in results] - results.remove() + results.delete() return recipients @property - def filter_types(self): + @dbconnection + def filter_types(self, store): """See `IMailingList`.""" - results = Store.of(self).find( - ContentFilter, - And(ContentFilter.mailing_list == self, - ContentFilter.filter_type == FilterType.filter_mime)) + results = store.query(ContentFilter).filter( + ContentFilter.mailing_list == self, + ContentFilter.filter_type == FilterType.filter_mime) for content_filter in results: yield content_filter.filter_pattern @filter_types.setter - def filter_types(self, sequence): + @dbconnection + def filter_types(self, store, sequence): """See `IMailingList`.""" # First, delete all existing MIME type filter patterns. - store = Store.of(self) - results = store.find( - ContentFilter, - And(ContentFilter.mailing_list == self, - ContentFilter.filter_type == FilterType.filter_mime)) - results.remove() + results = store.query(ContentFilter).filter( + ContentFilter.mailing_list == self, + ContentFilter.filter_type == FilterType.filter_mime) + results.delete() # Now add all the new filter types. for mime_type in sequence: content_filter = ContentFilter( @@ -366,25 +377,24 @@ class MailingList(Model): store.add(content_filter) @property - def pass_types(self): + @dbconnection + def pass_types(self, store): """See `IMailingList`.""" - results = Store.of(self).find( - ContentFilter, - And(ContentFilter.mailing_list == self, - ContentFilter.filter_type == FilterType.pass_mime)) + results = store.query(ContentFilter).filter( + ContentFilter.mailing_list == self, + ContentFilter.filter_type == FilterType.pass_mime) for content_filter in results: yield content_filter.filter_pattern @pass_types.setter - def pass_types(self, sequence): + @dbconnection + def pass_types(self, store, sequence): """See `IMailingList`.""" # First, delete all existing MIME type pass patterns. - store = Store.of(self) - results = store.find( - ContentFilter, - And(ContentFilter.mailing_list == self, - ContentFilter.filter_type == FilterType.pass_mime)) - results.remove() + results = store.query(ContentFilter).filter( + ContentFilter.mailing_list == self, + ContentFilter.filter_type == FilterType.pass_mime) + results.delete() # Now add all the new filter types. for mime_type in sequence: content_filter = ContentFilter( @@ -392,25 +402,24 @@ class MailingList(Model): store.add(content_filter) @property - def filter_extensions(self): + @dbconnection + def filter_extensions(self, store): """See `IMailingList`.""" - results = Store.of(self).find( - ContentFilter, - And(ContentFilter.mailing_list == self, - ContentFilter.filter_type == FilterType.filter_extension)) + results = store.query(ContentFilter).filter( + ContentFilter.mailing_list == self, + ContentFilter.filter_type == FilterType.filter_extension) for content_filter in results: yield content_filter.filter_pattern @filter_extensions.setter - def filter_extensions(self, sequence): + @dbconnection + def filter_extensions(self, store, sequence): """See `IMailingList`.""" # First, delete all existing file extensions filter patterns. - store = Store.of(self) - results = store.find( - ContentFilter, - And(ContentFilter.mailing_list == self, - ContentFilter.filter_type == FilterType.filter_extension)) - results.remove() + results = store.query(ContentFilter).filter( + ContentFilter.mailing_list == self, + ContentFilter.filter_type == FilterType.filter_extension) + results.delete() # Now add all the new filter types. for mime_type in sequence: content_filter = ContentFilter( @@ -418,25 +427,24 @@ class MailingList(Model): store.add(content_filter) @property - def pass_extensions(self): + @dbconnection + def pass_extensions(self, store): """See `IMailingList`.""" - results = Store.of(self).find( - ContentFilter, - And(ContentFilter.mailing_list == self, - ContentFilter.filter_type == FilterType.pass_extension)) + results = store.query(ContentFilter).filter( + ContentFilter.mailing_list == self, + ContentFilter.filter_type == FilterType.pass_extension) for content_filter in results: yield content_filter.pass_pattern @pass_extensions.setter - def pass_extensions(self, sequence): + @dbconnection + def pass_extensions(self, store, sequence): """See `IMailingList`.""" # First, delete all existing file extensions pass patterns. - store = Store.of(self) - results = store.find( - ContentFilter, - And(ContentFilter.mailing_list == self, - ContentFilter.filter_type == FilterType.pass_extension)) - results.remove() + results = store.query(ContentFilter).filter( + ContentFilter.mailing_list == self, + ContentFilter.filter_type == FilterType.pass_extension) + results.delete() # Now add all the new filter types. for mime_type in sequence: content_filter = ContentFilter( @@ -452,29 +460,26 @@ class MailingList(Model): elif role is MemberRole.moderator: return self.moderators else: - raise TypeError( - 'Undefined MemberRole: {0}'.format(role)) + raise TypeError('Undefined MemberRole: {}'.format(role)) - def subscribe(self, subscriber, role=MemberRole.member): + @dbconnection + def subscribe(self, store, subscriber, role=MemberRole.member): """See `IMailingList`.""" - store = Store.of(self) if IAddress.providedBy(subscriber): - member = store.find( - Member, + member = store.query(Member).filter( Member.role == role, Member.list_id == self._list_id, - Member._address == subscriber).one() + Member._address == subscriber).first() if member: raise AlreadySubscribedError( self.fqdn_listname, subscriber.email, role) elif IUser.providedBy(subscriber): if subscriber.preferred_address is None: raise MissingPreferredAddressError(subscriber) - member = store.find( - Member, + member = store.query(Member).filter( Member.role == role, Member.list_id == self._list_id, - Member._user == subscriber).one() + Member._user == subscriber).first() if member: raise AlreadySubscribedError( self.fqdn_listname, subscriber, role) @@ -494,12 +499,15 @@ class MailingList(Model): class AcceptableAlias(Model): """See `IAcceptableAlias`.""" - id = Int(primary=True) + __tablename__ = 'acceptablealias' - mailing_list_id = Int() - mailing_list = Reference(mailing_list_id, MailingList.id) + id = Column(Integer, primary_key=True) - alias = Unicode() + mailing_list_id = Column( + Integer, ForeignKey('mailinglist.id'), + index=True, nullable=False) + mailing_list = relationship('MailingList', backref='acceptable_alias') + alias = Column(Unicode, index=True, nullable=False) def __init__(self, mailing_list, alias): self.mailing_list = mailing_list @@ -514,29 +522,30 @@ class AcceptableAliasSet: def __init__(self, mailing_list): self._mailing_list = mailing_list - def clear(self): + @dbconnection + def clear(self, store): """See `IAcceptableAliasSet`.""" - Store.of(self._mailing_list).find( - AcceptableAlias, - AcceptableAlias.mailing_list == self._mailing_list).remove() + store.query(AcceptableAlias).filter( + AcceptableAlias.mailing_list == self._mailing_list).delete() - def add(self, alias): + @dbconnection + def add(self, store, alias): if not (alias.startswith('^') or '@' in alias): raise ValueError(alias) alias = AcceptableAlias(self._mailing_list, alias.lower()) - Store.of(self._mailing_list).add(alias) + store.add(alias) - def remove(self, alias): - Store.of(self._mailing_list).find( - AcceptableAlias, - And(AcceptableAlias.mailing_list == self._mailing_list, - AcceptableAlias.alias == alias.lower())).remove() + @dbconnection + def remove(self, store, alias): + store.query(AcceptableAlias).filter( + AcceptableAlias.mailing_list == self._mailing_list, + AcceptableAlias.alias == alias.lower()).delete() @property - def aliases(self): - aliases = Store.of(self._mailing_list).find( - AcceptableAlias, - AcceptableAlias.mailing_list == self._mailing_list) + @dbconnection + def aliases(self, store): + aliases = store.query(AcceptableAlias).filter( + AcceptableAlias.mailing_list_id == self._mailing_list.id) for alias in aliases: yield alias.alias @@ -546,12 +555,17 @@ class AcceptableAliasSet: class ListArchiver(Model): """See `IListArchiver`.""" - id = Int(primary=True) + __tablename__ = 'listarchiver' + + id = Column(Integer, primary_key=True) - mailing_list_id = Int() - mailing_list = Reference(mailing_list_id, MailingList.id) - name = Unicode() - _is_enabled = Bool() + mailing_list_id = Column( + Integer, ForeignKey('mailinglist.id'), + index=True, nullable=False) + mailing_list = relationship('MailingList') + + name = Column(Unicode, nullable=False) + _is_enabled = Column(Boolean) def __init__(self, mailing_list, archiver_name, system_archiver): self.mailing_list = mailing_list @@ -576,32 +590,32 @@ class ListArchiver(Model): @implementer(IListArchiverSet) class ListArchiverSet: - def __init__(self, mailing_list): + @dbconnection + def __init__(self, store, mailing_list): self._mailing_list = mailing_list system_archivers = {} for archiver in config.archivers: system_archivers[archiver.name] = archiver # Add any system enabled archivers which aren't already associated # with the mailing list. - store = Store.of(self._mailing_list) for archiver_name in system_archivers: - exists = store.find( - ListArchiver, - And(ListArchiver.mailing_list == mailing_list, - ListArchiver.name == archiver_name)).one() + exists = store.query(ListArchiver).filter( + ListArchiver.mailing_list == mailing_list, + ListArchiver.name == archiver_name).first() if exists is None: store.add(ListArchiver(mailing_list, archiver_name, system_archivers[archiver_name])) @property - def archivers(self): - entries = Store.of(self._mailing_list).find( - ListArchiver, ListArchiver.mailing_list == self._mailing_list) + @dbconnection + def archivers(self, store): + entries = store.query(ListArchiver).filter( + ListArchiver.mailing_list == self._mailing_list) for entry in entries: yield entry - def get(self, archiver_name): - return Store.of(self._mailing_list).find( - ListArchiver, - And(ListArchiver.mailing_list == self._mailing_list, - ListArchiver.name == archiver_name)).one() + @dbconnection + def get(self, store, archiver_name): + return store.query(ListArchiver).filter( + ListArchiver.mailing_list == self._mailing_list, + ListArchiver.name == archiver_name).first() diff --git a/src/mailman/model/member.py b/src/mailman/model/member.py index 438796811..9da9d5d0d 100644 --- a/src/mailman/model/member.py +++ b/src/mailman/model/member.py @@ -24,8 +24,8 @@ __all__ = [ 'Member', ] -from storm.locals import Int, Reference, Unicode -from storm.properties import UUID +from sqlalchemy import Column, ForeignKey, Integer, Unicode +from sqlalchemy.orm import relationship from zope.component import getUtility from zope.event import notify from zope.interface import implementer @@ -33,7 +33,7 @@ from zope.interface import implementer from mailman.core.constants import system_preferences from mailman.database.model import Model from mailman.database.transaction import dbconnection -from mailman.database.types import Enum +from mailman.database.types import Enum, UUID from mailman.interfaces.action import Action from mailman.interfaces.address import IAddress from mailman.interfaces.listmanager import IListManager @@ -52,18 +52,20 @@ uid_factory = UniqueIDFactory(context='members') class Member(Model): """See `IMember`.""" - id = Int(primary=True) - _member_id = UUID() - role = Enum(MemberRole) - list_id = Unicode() - moderation_action = Enum(Action) + __tablename__ = 'member' - address_id = Int() - _address = Reference(address_id, 'Address.id') - preferences_id = Int() - preferences = Reference(preferences_id, 'Preferences.id') - user_id = Int() - _user = Reference(user_id, 'User.id') + id = Column(Integer, primary_key=True) + _member_id = Column(UUID) + role = Column(Enum(MemberRole)) + list_id = Column(Unicode) + moderation_action = Column(Enum(Action)) + + address_id = Column(Integer, ForeignKey('address.id')) + _address = relationship('Address') + preferences_id = Column(Integer, ForeignKey('preferences.id')) + preferences = relationship('Preferences') + user_id = Column(Integer, ForeignKey('user.id')) + _user = relationship('User') def __init__(self, role, list_id, subscriber): self._member_id = uid_factory.new_uid() @@ -198,5 +200,5 @@ class Member(Model): """See `IMember`.""" # Yes, this must get triggered before self is deleted. notify(UnsubscriptionEvent(self.mailing_list, self)) - store.remove(self.preferences) - store.remove(self) + store.delete(self.preferences) + store.delete(self) diff --git a/src/mailman/model/message.py b/src/mailman/model/message.py index 2d697c30b..691861d46 100644 --- a/src/mailman/model/message.py +++ b/src/mailman/model/message.py @@ -24,7 +24,7 @@ __all__ = [ 'Message', ] -from storm.locals import AutoReload, Int, RawStr, Unicode +from sqlalchemy import Column, Integer, LargeBinary, Unicode from zope.interface import implementer from mailman.database.model import Model @@ -37,11 +37,13 @@ from mailman.interfaces.messages import IMessage class Message(Model): """A message in the message store.""" - id = Int(primary=True, default=AutoReload) - message_id = Unicode() - message_id_hash = RawStr() - path = RawStr() + __tablename__ = 'message' + + id = Column(Integer, primary_key=True) # This is a Messge-ID field representation, not a database row id. + message_id = Column(Unicode) + message_id_hash = Column(LargeBinary) + path = Column(LargeBinary) @dbconnection def __init__(self, store, message_id, message_id_hash, path): diff --git a/src/mailman/model/messagestore.py b/src/mailman/model/messagestore.py index a4950e8c9..19fa8133f 100644 --- a/src/mailman/model/messagestore.py +++ b/src/mailman/model/messagestore.py @@ -54,12 +54,13 @@ class MessageStore: def add(self, store, message): # Ensure that the message has the requisite headers. message_ids = message.get_all('message-id', []) - if len(message_ids) <> 1: + if len(message_ids) != 1: raise ValueError('Exactly one Message-ID header required') # Calculate and insert the X-Message-ID-Hash. message_id = message_ids[0] # Complain if the Message-ID already exists in the storage. - existing = store.find(Message, Message.message_id == message_id).one() + existing = store.query(Message).filter( + Message.message_id == message_id).first() if existing is not None: raise ValueError( 'Message ID already exists in message store: {0}'.format( @@ -80,9 +81,9 @@ class MessageStore: # providing a unique serial number, but to get this information, we # have to use a straight insert instead of relying on Elixir to create # the object. - row = Message(message_id=message_id, - message_id_hash=hash32, - path=relpath) + Message(message_id=message_id, + message_id_hash=hash32, + path=relpath) # Now calculate the full file system path. path = os.path.join(config.MESSAGES_DIR, relpath) # Write the file to the path, but catch the appropriate exception in @@ -95,7 +96,7 @@ class MessageStore: pickle.dump(message, fp, -1) break except IOError as error: - if error.errno <> errno.ENOENT: + if error.errno != errno.ENOENT: raise makedirs(os.path.dirname(path)) return hash32 @@ -107,7 +108,7 @@ class MessageStore: @dbconnection def get_message_by_id(self, store, message_id): - row = store.find(Message, message_id=message_id).one() + row = store.query(Message).filter_by(message_id=message_id).first() if row is None: return None return self._get_message(row) @@ -116,11 +117,11 @@ class MessageStore: def get_message_by_hash(self, store, message_id_hash): # It's possible the hash came from a message header, in which case it # will be a Unicode. However when coming from source code, it may be - # an 8-string. Coerce to the latter if necessary; it must be - # US-ASCII. - if isinstance(message_id_hash, unicode): + # bytes object. Coerce to the latter if necessary; it must be ASCII. + if not isinstance(message_id_hash, bytes): message_id_hash = message_id_hash.encode('ascii') - row = store.find(Message, message_id_hash=message_id_hash).one() + row = store.query(Message).filter_by( + message_id_hash=message_id_hash).first() if row is None: return None return self._get_message(row) @@ -128,14 +129,14 @@ class MessageStore: @property @dbconnection def messages(self, store): - for row in store.find(Message): + for row in store.query(Message).all(): yield self._get_message(row) @dbconnection def delete_message(self, store, message_id): - row = store.find(Message, message_id=message_id).one() + row = store.query(Message).filter_by(message_id=message_id).first() if row is None: raise LookupError(message_id) path = os.path.join(config.MESSAGES_DIR, row.path) os.remove(path) - store.remove(row) + store.delete(row) diff --git a/src/mailman/model/mime.py b/src/mailman/model/mime.py index 570112a97..dc6a54437 100644 --- a/src/mailman/model/mime.py +++ b/src/mailman/model/mime.py @@ -25,7 +25,8 @@ __all__ = [ ] -from storm.locals import Int, Reference, Unicode +from sqlalchemy import Column, ForeignKey, Integer, Unicode +from sqlalchemy.orm import relationship from zope.interface import implementer from mailman.database.model import Model @@ -38,13 +39,15 @@ from mailman.interfaces.mime import IContentFilter, FilterType class ContentFilter(Model): """A single filter criteria.""" - id = Int(primary=True) + __tablename__ = 'contentfilter' - mailing_list_id = Int() - mailing_list = Reference(mailing_list_id, 'MailingList.id') + id = Column(Integer, primary_key=True) - filter_type = Enum(FilterType) - filter_pattern = Unicode() + mailing_list_id = Column(Integer, ForeignKey('mailinglist.id'), index=True) + mailing_list = relationship('MailingList') + + filter_type = Column(Enum(FilterType)) + filter_pattern = Column(Unicode) def __init__(self, mailing_list, filter_pattern, filter_type): self.mailing_list = mailing_list diff --git a/src/mailman/model/pending.py b/src/mailman/model/pending.py index 17513015c..49b12c16a 100644 --- a/src/mailman/model/pending.py +++ b/src/mailman/model/pending.py @@ -31,7 +31,9 @@ import random import hashlib from lazr.config import as_timedelta -from storm.locals import DateTime, Int, RawStr, ReferenceSet, Unicode +from sqlalchemy import ( + Column, DateTime, ForeignKey, Integer, LargeBinary, Unicode) +from sqlalchemy.orm import relationship from zope.interface import implementer from zope.interface.verify import verifyObject @@ -49,31 +51,35 @@ from mailman.utilities.modules import call_name class PendedKeyValue(Model): """A pended key/value pair, tied to a token.""" + __tablename__ = 'pendedkeyvalue' + + id = Column(Integer, primary_key=True) + key = Column(Unicode) + value = Column(Unicode) + pended_id = Column(Integer, ForeignKey('pended.id'), index=True) + def __init__(self, key, value): self.key = key self.value = value - id = Int(primary=True) - key = Unicode() - value = Unicode() - pended_id = Int() - @implementer(IPended) class Pended(Model): """A pended event, tied to a token.""" + __tablename__ = 'pended' + + id = Column(Integer, primary_key=True) + token = Column(LargeBinary) + expiration_date = Column(DateTime) + key_values = relationship('PendedKeyValue') + def __init__(self, token, expiration_date): super(Pended, self).__init__() self.token = token self.expiration_date = expiration_date - id = Int(primary=True) - token = RawStr() - expiration_date = DateTime() - key_values = ReferenceSet(id, PendedKeyValue.pended_id) - @implementer(IPendable) @@ -105,7 +111,7 @@ class Pendings: token = hashlib.sha1(repr(x)).hexdigest() # In practice, we'll never get a duplicate, but we'll be anal # about checking anyway. - if store.find(Pended, token=token).count() == 0: + if store.query(Pended).filter_by(token=token).count() == 0: break else: raise AssertionError('Could not find a valid pendings token') @@ -114,10 +120,10 @@ class Pendings: token=token, expiration_date=now() + lifetime) for key, value in pendable.items(): - if isinstance(key, str): - key = unicode(key, 'utf-8') - if isinstance(value, str): - value = unicode(value, 'utf-8') + if isinstance(key, bytes): + key = key.decode('utf-8') + if isinstance(value, bytes): + value = value.decode('utf-8') elif type(value) is int: value = '__builtin__.int\1%s' % value elif type(value) is float: @@ -129,7 +135,7 @@ class Pendings: value = ('mailman.model.pending.unpack_list\1' + '\2'.join(value)) keyval = PendedKeyValue(key=key, value=value) - pending.key_values.add(keyval) + pending.key_values.append(keyval) store.add(pending) return token @@ -137,7 +143,7 @@ class Pendings: def confirm(self, store, token, expunge=True): # Token can come in as a unicode, but it's stored in the database as # bytes. They must be ascii. - pendings = store.find(Pended, token=str(token)) + pendings = store.query(Pended).filter_by(token=str(token)) if pendings.count() == 0: return None assert pendings.count() == 1, ( @@ -146,31 +152,32 @@ class Pendings: pendable = UnpendedPendable() # Find all PendedKeyValue entries that are associated with the pending # object's ID. Watch out for type conversions. - for keyvalue in store.find(PendedKeyValue, - PendedKeyValue.pended_id == pending.id): + entries = store.query(PendedKeyValue).filter( + PendedKeyValue.pended_id == pending.id) + for keyvalue in entries: if keyvalue.value is not None and '\1' in keyvalue.value: type_name, value = keyvalue.value.split('\1', 1) pendable[keyvalue.key] = call_name(type_name, value) else: pendable[keyvalue.key] = keyvalue.value if expunge: - store.remove(keyvalue) + store.delete(keyvalue) if expunge: - store.remove(pending) + store.delete(pending) return pendable @dbconnection def evict(self, store): right_now = now() - for pending in store.find(Pended): + for pending in store.query(Pended).all(): if pending.expiration_date < right_now: # Find all PendedKeyValue entries that are associated with the # pending object's ID. - q = store.find(PendedKeyValue, - PendedKeyValue.pended_id == pending.id) + q = store.query(PendedKeyValue).filter( + PendedKeyValue.pended_id == pending.id) for keyvalue in q: - store.remove(keyvalue) - store.remove(pending) + store.delete(keyvalue) + store.delete(pending) diff --git a/src/mailman/model/preferences.py b/src/mailman/model/preferences.py index 83271d7d6..1278f80b7 100644 --- a/src/mailman/model/preferences.py +++ b/src/mailman/model/preferences.py @@ -25,7 +25,7 @@ __all__ = [ ] -from storm.locals import Bool, Int, Unicode +from sqlalchemy import Boolean, Column, Integer, Unicode from zope.component import getUtility from zope.interface import implementer @@ -41,14 +41,16 @@ from mailman.interfaces.preferences import IPreferences class Preferences(Model): """See `IPreferences`.""" - id = Int(primary=True) - acknowledge_posts = Bool() - hide_address = Bool() - _preferred_language = Unicode(name='preferred_language') - receive_list_copy = Bool() - receive_own_postings = Bool() - delivery_mode = Enum(DeliveryMode) - delivery_status = Enum(DeliveryStatus) + __tablename__ = 'preferences' + + id = Column(Integer, primary_key=True) + acknowledge_posts = Column(Boolean) + hide_address = Column(Boolean) + _preferred_language = Column('preferred_language', Unicode) + receive_list_copy = Column(Boolean) + receive_own_postings = Column(Boolean) + delivery_mode = Column(Enum(DeliveryMode)) + delivery_status = Column(Enum(DeliveryStatus)) def __repr__(self): return '<Preferences object at {0:#x}>'.format(id(self)) diff --git a/src/mailman/model/requests.py b/src/mailman/model/requests.py index f3ad54797..6b130196d 100644 --- a/src/mailman/model/requests.py +++ b/src/mailman/model/requests.py @@ -26,7 +26,8 @@ __all__ = [ from cPickle import dumps, loads from datetime import timedelta -from storm.locals import AutoReload, Int, RawStr, Reference, Unicode +from sqlalchemy import Column, ForeignKey, Integer, LargeBinary, Unicode +from sqlalchemy.orm import relationship from zope.component import getUtility from zope.interface import implementer @@ -68,25 +69,25 @@ class ListRequests: @property @dbconnection def count(self, store): - return store.find(_Request, mailing_list=self.mailing_list).count() + return store.query(_Request).filter_by( + mailing_list=self.mailing_list).count() @dbconnection def count_of(self, store, request_type): - return store.find( - _Request, + return store.query(_Request).filter_by( mailing_list=self.mailing_list, request_type=request_type).count() @property @dbconnection def held_requests(self, store): - results = store.find(_Request, mailing_list=self.mailing_list) + results = store.query(_Request).filter_by( + mailing_list=self.mailing_list) for request in results: yield request @dbconnection def of_type(self, store, request_type): - results = store.find( - _Request, + results = store.query(_Request).filter_by( mailing_list=self.mailing_list, request_type=request_type) for request in results: yield request @@ -104,11 +105,15 @@ class ListRequests: data_hash = token request = _Request(key, request_type, self.mailing_list, data_hash) store.add(request) + # XXX The caller needs a valid id immediately, so flush the changes + # now to the SA transaction context. Otherwise .id would not be + # valid. Hopefully this has no unintended side-effects. + store.flush() return request.id @dbconnection def get_request(self, store, request_id, request_type=None): - result = store.get(_Request, request_id) + result = store.query(_Request).get(request_id) if result is None: return None if request_type is not None and result.request_type != request_type: @@ -117,6 +122,8 @@ class ListRequests: return result.key, None pendable = getUtility(IPendings).confirm( result.data_hash, expunge=False) + if pendable is None: + return None data = dict() # Unpickle any non-Unicode values. for key, value in pendable.items(): @@ -130,25 +137,27 @@ class ListRequests: @dbconnection def delete_request(self, store, request_id): - request = store.get(_Request, request_id) + request = store.query(_Request).get(request_id) if request is None: raise KeyError(request_id) # Throw away the pended data. getUtility(IPendings).confirm(request.data_hash) - store.remove(request) + store.delete(request) class _Request(Model): """Table for mailing list hold requests.""" - id = Int(primary=True, default=AutoReload) - key = Unicode() - request_type = Enum(RequestType) - data_hash = RawStr() + __tablename__ = '_request' + + id = Column(Integer, primary_key=True) + key = Column(Unicode) + request_type = Column(Enum(RequestType)) + data_hash = Column(LargeBinary) - mailing_list_id = Int() - mailing_list = Reference(mailing_list_id, 'MailingList.id') + mailing_list_id = Column(Integer, ForeignKey('mailinglist.id'), index=True) + mailing_list = relationship('MailingList') def __init__(self, key, request_type, mailing_list, data_hash): super(_Request, self).__init__() diff --git a/src/mailman/model/roster.py b/src/mailman/model/roster.py index 5a6a13269..54bc11617 100644 --- a/src/mailman/model/roster.py +++ b/src/mailman/model/roster.py @@ -37,7 +37,7 @@ __all__ = [ ] -from storm.expr import And, Or +from sqlalchemy import and_, or_ from zope.interface import implementer from mailman.database.transaction import dbconnection @@ -65,8 +65,7 @@ class AbstractRoster: @dbconnection def _query(self, store): - return store.find( - Member, + return store.query(Member).filter( Member.list_id == self._mlist.list_id, Member.role == self.role) @@ -104,8 +103,7 @@ class AbstractRoster: @dbconnection def get_member(self, store, address): """See `IRoster`.""" - results = store.find( - Member, + results = store.query(Member).filter( Member.list_id == self._mlist.list_id, Member.role == self.role, Address.email == address, @@ -160,22 +158,20 @@ class AdministratorRoster(AbstractRoster): @dbconnection def _query(self, store): - return store.find( - Member, + return store.query(Member).filter( Member.list_id == self._mlist.list_id, - Or(Member.role == MemberRole.owner, - Member.role == MemberRole.moderator)) + or_(Member.role == MemberRole.owner, + Member.role == MemberRole.moderator)) @dbconnection def get_member(self, store, address): """See `IRoster`.""" - results = store.find( - Member, - Member.list_id == self._mlist.list_id, - Or(Member.role == MemberRole.moderator, - Member.role == MemberRole.owner), - Address.email == address, - Member.address_id == Address.id) + results = store.query(Member).filter( + Member.list_id == self._mlist.list_id, + or_(Member.role == MemberRole.moderator, + Member.role == MemberRole.owner), + Address.email == address, + Member.address_id == Address.id) if results.count() == 0: return None elif results.count() == 1: @@ -206,10 +202,9 @@ class DeliveryMemberRoster(AbstractRoster): :return: A generator of members. :rtype: generator """ - results = store.find( - Member, - And(Member.list_id == self._mlist.list_id, - Member.role == MemberRole.member)) + results = store.query(Member).filter_by( + list_id = self._mlist.list_id, + role = MemberRole.member) for member in results: if member.delivery_mode in delivery_modes: yield member @@ -250,7 +245,7 @@ class Subscribers(AbstractRoster): @dbconnection def _query(self, store): - return store.find(Member, Member.list_id == self._mlist.list_id) + return store.query(Member).filter_by(list_id = self._mlist.list_id) @@ -265,12 +260,11 @@ class Memberships: @dbconnection def _query(self, store): - results = store.find( - Member, - Or(Member.user_id == self._user.id, - And(Address.user_id == self._user.id, - Member.address_id == Address.id))) - return results.config(distinct=True) + results = store.query(Member).filter( + or_(Member.user_id == self._user.id, + and_(Address.user_id == self._user.id, + Member.address_id == Address.id))) + return results.distinct() @property def member_count(self): @@ -297,8 +291,7 @@ class Memberships: @dbconnection def get_member(self, store, address): """See `IRoster`.""" - results = store.find( - Member, + results = store.query(Member).filter( Member.address_id == Address.id, Address.user_id == self._user.id) if results.count() == 0: diff --git a/src/mailman/model/tests/test_listmanager.py b/src/mailman/model/tests/test_listmanager.py index 2d3a4e3dc..b290138f3 100644 --- a/src/mailman/model/tests/test_listmanager.py +++ b/src/mailman/model/tests/test_listmanager.py @@ -29,11 +29,11 @@ __all__ = [ import unittest -from storm.locals import Store from zope.component import getUtility from mailman.app.lifecycle import create_list from mailman.app.moderator import hold_message +from mailman.config import config from mailman.interfaces.listmanager import ( IListManager, ListCreatedEvent, ListCreatingEvent, ListDeletedEvent, ListDeletingEvent) @@ -80,6 +80,15 @@ class TestListManager(unittest.TestCase): self.assertTrue(isinstance(self._events[1], ListDeletedEvent)) self.assertEqual(self._events[1].fqdn_listname, 'another@example.com') + def test_list_manager_list_ids(self): + # You can get all the list ids for all the existing mailing lists. + create_list('ant@example.com') + create_list('bee@example.com') + create_list('cat@example.com') + self.assertEqual( + sorted(getUtility(IListManager).list_ids), + ['ant.example.com', 'bee.example.com', 'cat.example.com']) + class TestListLifecycleEvents(unittest.TestCase): @@ -139,9 +148,8 @@ Message-ID: <argon> for name in filter_names: setattr(self._ant, name, ['test-filter-1', 'test-filter-2']) getUtility(IListManager).delete(self._ant) - store = Store.of(self._ant) - filters = store.find(ContentFilter, - ContentFilter.mailing_list == self._ant) + filters = config.db.store.query(ContentFilter).filter_by( + mailing_list = self._ant) self.assertEqual(filters.count(), 0) diff --git a/src/mailman/model/tests/test_requests.py b/src/mailman/model/tests/test_requests.py index dc1b9b849..419c6077f 100644 --- a/src/mailman/model/tests/test_requests.py +++ b/src/mailman/model/tests/test_requests.py @@ -70,10 +70,10 @@ Something else. # Calling hold_request() with a bogus request type is an error. with self.assertRaises(TypeError) as cm: self._requests_db.hold_request(5, 'foo') - self.assertEqual(cm.exception.message, 5) + self.assertEqual(cm.exception.args[0], 5) def test_delete_missing_request(self): # Trying to delete a missing request is an error. with self.assertRaises(KeyError) as cm: self._requests_db.delete_request(801) - self.assertEqual(cm.exception.message, 801) + self.assertEqual(cm.exception.args[0], 801) diff --git a/src/mailman/model/uid.py b/src/mailman/model/uid.py index c60d0f1eb..72ddd7b5a 100644 --- a/src/mailman/model/uid.py +++ b/src/mailman/model/uid.py @@ -25,11 +25,12 @@ __all__ = [ ] -from storm.locals import Int -from storm.properties import UUID + +from sqlalchemy import Column, Integer from mailman.database.model import Model from mailman.database.transaction import dbconnection +from mailman.database.types import UUID @@ -45,8 +46,11 @@ class UID(Model): There is no interface for this class, because it's purely an internal implementation detail. """ - id = Int(primary=True) - uid = UUID() + + __tablename__ = 'uid' + + id = Column(Integer, primary_key=True) + uid = Column(UUID, index=True) @dbconnection def __init__(self, store, uid): @@ -70,7 +74,7 @@ class UID(Model): :type uid: unicode :raises ValueError: if the id is not unique. """ - existing = store.find(UID, uid=uid) + existing = store.query(UID).filter_by(uid=uid) if existing.count() != 0: raise ValueError(uid) return UID(uid) diff --git a/src/mailman/model/user.py b/src/mailman/model/user.py index f2c09c626..ab581fdc8 100644 --- a/src/mailman/model/user.py +++ b/src/mailman/model/user.py @@ -24,14 +24,15 @@ __all__ = [ 'User', ] -from storm.locals import ( - DateTime, Int, RawStr, Reference, ReferenceSet, Unicode) -from storm.properties import UUID +from sqlalchemy import ( + Column, DateTime, ForeignKey, Integer, LargeBinary, Unicode) +from sqlalchemy.orm import relationship, backref from zope.event import notify from zope.interface import implementer from mailman.database.model import Model from mailman.database.transaction import dbconnection +from mailman.database.types import UUID from mailman.interfaces.address import ( AddressAlreadyLinkedError, AddressNotLinkedError) from mailman.interfaces.user import ( @@ -51,24 +52,38 @@ uid_factory = UniqueIDFactory(context='users') class User(Model): """Mailman users.""" - id = Int(primary=True) - display_name = Unicode() - _password = RawStr(name='password') - _user_id = UUID() - _created_on = DateTime() + __tablename__ = 'user' - addresses = ReferenceSet(id, 'Address.user_id') - _preferred_address_id = Int() - _preferred_address = Reference(_preferred_address_id, 'Address.id') - preferences_id = Int() - preferences = Reference(preferences_id, 'Preferences.id') + id = Column(Integer, primary_key=True) + display_name = Column(Unicode) + _password = Column('password', LargeBinary) + _user_id = Column(UUID, index=True) + _created_on = Column(DateTime) + + addresses = relationship( + 'Address', backref='user', + primaryjoin=(id==Address.user_id)) + + _preferred_address_id = Column( + Integer, + ForeignKey('address.id', use_alter=True, + name='_preferred_address', + ondelete='SET NULL')) + + _preferred_address = relationship( + 'Address', primaryjoin=(_preferred_address_id==Address.id), + post_update=True) + + preferences_id = Column(Integer, ForeignKey('preferences.id'), index=True) + preferences = relationship( + 'Preferences', backref=backref('user', uselist=False)) @dbconnection def __init__(self, store, display_name=None, preferences=None): super(User, self).__init__() self._created_on = date_factory.now() user_id = uid_factory.new_uid() - assert store.find(User, _user_id=user_id).count() == 0, ( + assert store.query(User).filter_by(_user_id=user_id).count() == 0, ( 'Duplicate user id {0}'.format(user_id)) self._user_id = user_id self.display_name = ('' if display_name is None else display_name) @@ -138,7 +153,7 @@ class User(Model): @dbconnection def controls(self, store, email): """See `IUser`.""" - found = store.find(Address, email=email) + found = store.query(Address).filter_by(email=email) if found.count() == 0: return False assert found.count() == 1, 'Unexpected count' @@ -148,7 +163,7 @@ class User(Model): def register(self, store, email, display_name=None): """See `IUser`.""" # First, see if the address already exists - address = store.find(Address, email=email).one() + address = store.query(Address).filter_by(email=email).first() if address is None: if display_name is None: display_name = '' diff --git a/src/mailman/model/usermanager.py b/src/mailman/model/usermanager.py index 6f4a7ff5c..726aa6120 100644 --- a/src/mailman/model/usermanager.py +++ b/src/mailman/model/usermanager.py @@ -52,12 +52,12 @@ class UserManager: @dbconnection def delete_user(self, store, user): """See `IUserManager`.""" - store.remove(user) + store.delete(user) @dbconnection def get_user(self, store, email): """See `IUserManager`.""" - addresses = store.find(Address, email=email.lower()) + addresses = store.query(Address).filter_by(email=email.lower()) if addresses.count() == 0: return None return addresses.one().user @@ -65,7 +65,7 @@ class UserManager: @dbconnection def get_user_by_id(self, store, user_id): """See `IUserManager`.""" - users = store.find(User, _user_id=user_id) + users = store.query(User).filter_by(_user_id=user_id) if users.count() == 0: return None return users.one() @@ -74,13 +74,13 @@ class UserManager: @dbconnection def users(self, store): """See `IUserManager`.""" - for user in store.find(User): + for user in store.query(User).all(): yield user @dbconnection def create_address(self, store, email, display_name=None): """See `IUserManager`.""" - addresses = store.find(Address, email=email.lower()) + addresses = store.query(Address).filter(Address.email==email.lower()) if addresses.count() == 1: found = addresses[0] raise ExistingAddressError(found.original_email) @@ -101,12 +101,12 @@ class UserManager: # unlinked before the address can be deleted. if address.user: address.user.unlink(address) - store.remove(address) + store.delete(address) @dbconnection def get_address(self, store, email): """See `IUserManager`.""" - addresses = store.find(Address, email=email.lower()) + addresses = store.query(Address).filter_by(email=email.lower()) if addresses.count() == 0: return None return addresses.one() @@ -115,12 +115,12 @@ class UserManager: @dbconnection def addresses(self, store): """See `IUserManager`.""" - for address in store.find(Address): + for address in store.query(Address).all(): yield address @property @dbconnection def members(self, store): """See `IUserManager.""" - for member in store.find(Member): + for member in store.query(Member).all(): yield member diff --git a/src/mailman/model/version.py b/src/mailman/model/version.py deleted file mode 100644 index e99fb0d1c..000000000 --- a/src/mailman/model/version.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (C) 2007-2014 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Model class for version numbers.""" - -from __future__ import absolute_import, print_function, unicode_literals - -__metaclass__ = type -__all__ = [ - 'Version', - ] - -from storm.locals import Int, Unicode -from mailman.database.model import Model - - - -class Version(Model): - id = Int(primary=True) - component = Unicode() - version = Unicode() - - # The testing machinery will generally reset all tables, however because - # this table tracks schema migrations, we do not want to reset it. - PRESERVE = True - - def __init__(self, component, version): - super(Version, self).__init__() - self.component = component - self.version = version |
