diff --git a/README.rst b/README.rst index 66d88b5..0c1cbf0 100644 --- a/README.rst +++ b/README.rst @@ -40,15 +40,15 @@ traduki usage example: """Language chain (fallback rule) callback for our project.""" return {'*': request.locale} - traduki.initialize(Base, ['en', 'ru'], get_current_language, get_language_chain) + i18n_attributes = traduki.initialize(Base, ['en', 'ru'], get_current_language, get_language_chain) Session = sessionmaker(bind=engine) sess = Session() class MyModel(Base) - title_id = traduki.i18n_column(nullable=False, unique=False) - title = traduki.i18n_relation(title_id) + title_id = i18n_attributes.i18n_column(nullable=False, unique=False) + title = i18n_attributes.i18n_relation(title_id) """Title.""" my_object = MyModel() diff --git a/setup.py b/setup.py index 4cc7f08..f34d05d 100755 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ def run_tests(self): author='Paylogic International', author_email='developers@paylogic.com', license='MIT', + url='https://github.com/paylogic/traduki', install_requires=[ 'SQLAlchemy' ], diff --git a/tests/test_i18n.py b/tests/test_i18n.py index a099a29..d9fc417 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -10,26 +10,32 @@ import traduki -@pytest.fixture +Base = declarative_base() + + +@pytest.fixture(scope='session') def languages(): """Supported languages.""" return ['en', 'pt'] -@pytest.fixture -def model_class(languages): - """Create test model class.""" - Base = declarative_base() +@pytest.fixture(scope='session') +def i18n_attributes(languages): + """i18n attributes""" + return traduki.initialize(Base, languages, lambda: 'en', lambda: {}) - traduki.initialize(Base, languages, lambda: 'en', lambda: {}) + +@pytest.fixture(scope='session') +def model_class(i18n_attributes): + """Create test model class.""" class TestModel(Base): __tablename__ = 'test_table' - id = Column(Integer, primary_key=True) + id = Column(Integer, primary_key=True, autoincrement=True) - title_id = traduki.i18n_column(nullable=False, unique=False) - title = traduki.i18n_relation(title_id) + title_id = i18n_attributes.i18n_column(nullable=False, unique=False) + title = i18n_attributes.i18n_relation(title_id) """Title.""" def save(): @@ -38,22 +44,6 @@ def save(): return TestModel -@pytest.fixture -def model(model_class): - """Create test model instance.""" - return model_class(id=1) - - -@pytest.fixture -def model_form_class(model_class): - """Create test model form class.""" - class TestModelForm(traduki.SAModelForm): - class Meta: - model = model_class - - return TestModelForm - - @pytest.fixture def query(model_class, session): """Create SQLAlchemy Query for model_class.""" @@ -67,7 +57,7 @@ def session(request, engine): return session -@pytest.fixture +@pytest.fixture(scope='session') def engine(request, model_class): """SQLAlchemy engine.""" engine = create_engine('sqlite:///:memory:') @@ -75,23 +65,32 @@ def engine(request, model_class): return engine -def test_language_fields(model_class, languages): - """Check that Translation class is properly generated during initialize.""" - from traduki import sqla - assert set(dir(sqla.Translation)).issuperset(languages) - for lang in languages: - assert getattr(sqla.Translation, lang).type.__class__ == UnicodeText - +@pytest.fixture +def model_title(): + """Model title.""" + return {'en': 'En Title', 'pt': 'Pt Title'} -def test_i18n_field(query, model, session): - """Test i18n field.""" - model.title = {'en': 'En Title', 'pt': 'Pt Title'} +@pytest.fixture +def model(session, model_class, model_title): + """Model instance.""" + model = model_class(title=model_title) session.add(model) session.commit() session.refresh(model) + return model + + +def test_language_fields(model_class, languages, i18n_attributes): + """Check that Translation class is properly generated during initialize.""" + assert set(dir(i18n_attributes.Translation)).issuperset(languages) + for lang in languages: + assert getattr(i18n_attributes.Translation, lang).type.__class__ == UnicodeText - assert model.title.get_dict() == {'en': 'En Title', 'pt': 'Pt Title'} + +def test_i18n_field(query, model, model_title): + """Test i18n field.""" + assert model.title.get_dict() == model_title assert model.title.get_text() == 'En Title' @@ -99,6 +98,19 @@ def test_class_custom_table(languages): """Test initialization with custom table.""" Base = declarative_base() - traduki.initialize(Base, languages, lambda: 'en', lambda: {}, attributes={'__tablename__': 'custom_table'}) + i18n_attributes = traduki.initialize( + Base, languages, lambda: 'en', lambda: {}, attributes={'__tablename__': 'custom_table'}) + + assert i18n_attributes.Translation.__tablename__ == 'custom_table' + + +def test_set(model): + """Test changing of the field value.""" + model.title = {'en': 'New'} + assert model.title.get_dict() == {'en': 'New'} + - assert traduki.sqla.Translation.__tablename__ == 'custom_table' +def test_comparator(session, model, model_class): + """Test field comparator.""" + assert model in session.query(model_class).filter(model_class.title.startswith('En Title')) + assert model not in session.query(model_class).filter(model_class.title.startswith('Pt Title')) diff --git a/traduki/__init__.py b/traduki/__init__.py index 292d986..75498d2 100644 --- a/traduki/__init__.py +++ b/traduki/__init__.py @@ -1,3 +1,3 @@ -from traduki.sqla import initialize, i18n_column, i18n_relation +from traduki.sqla import initialize -__all__ = (initialize.__name__, i18n_column.__name__, i18n_relation.__name__) +__all__ = (initialize.__name__) diff --git a/traduki/sqla.py b/traduki/sqla.py index 3032485..2f6962f 100644 --- a/traduki/sqla.py +++ b/traduki/sqla.py @@ -6,6 +6,8 @@ public functions: initialize i18n_column, i18n_relation. """ +import collections + from sqlalchemy import Column, Integer, ForeignKey, UnicodeText from sqlalchemy.exc import ArgumentError from sqlalchemy.orm import relationship @@ -16,7 +18,8 @@ from traduki import config from traduki import helpers -Translation = None +# Tuple class describing the return value of the initialization function +Attributes = collections.namedtuple('Attributes', ['Translation', 'i18n_column', 'i18n_relation']) class TranslationMixin(object): @@ -54,7 +57,7 @@ def __nonzero__(self): return bool(unicode(self)) -def initialize(base, languages, get_current_language_callback, get_language_chain_callback): +def initialize(base, languages, get_current_language_callback, get_language_chain_callback, attributes=None): """Initialize using given declarative base. :param base: SQLAlchemy declarative base class @@ -62,6 +65,12 @@ def initialize(base, languages, get_current_language_callback, get_language_chai :param get_current_language_callback: function which returns current language code :param get_language_chain_callback: function which returns language chain `dict` in format: {'': ''} + :param attributes: `dict` of future Translation class additional attributes or overrides. + For example: {'__tablename__': 'some_other_table' + :return: `Attributes` object which contains: + * Translation class + * i18n_column helper to create i18n-aware columns on user models + * i18n_relation helper to create i18n-aware relationships on user models """ @@ -69,105 +78,104 @@ def initialize(base, languages, get_current_language_callback, get_language_chai config.LANGUAGE_CHAIN_CALLBACK = get_language_chain_callback config.LANGUAGES = languages - attributes = dict(((lang, Column(UnicodeText, nullable=True)) for lang in languages)) + if attributes is None: + attributes = {} - global Translation + attributes.update(dict(((lang, Column(UnicodeText, nullable=True)) for lang in languages))) Translation = type('Translation', (TranslationMixin, base), attributes) - -class TranslationComparator(RelationshipProperty.Comparator): - """ - RelationshipProperty.Comparator modification to enable the use of like, startswith, endswith and contains directly - on the relationship. Each of the comparators compares against the first non-null item in the list of columns - (languages) available, in order of preference (see ordered_languages() in this module). - - Raises NotImplementedError exception when using not with like, contains or startswith. - All the `like` operations will look into next language if the specified language is not filled in. - """ - - LIKE_OPS = set([ - oper.like_op, oper.contains_op, oper.startswith_op, - oper.endswith_op]) - - # Only the operators in LIKE_OPS are allowed on this relationship - def operate(self, op, *other, **kw): - if op in self.LIKE_OPS: - return self._do_compare(op, *other, **kw) - else: - raise NotImplementedError() - - # contains is redefined in RelationshipProperty.Comparator, so we need to override it - def contains(self, other, escape=None): - return self.operate(oper.contains_op, other, escape=escape) - - def _do_compare(self, op, other, escape): - """Perform coalesced comparison operations to the columns of Translation model. - Looking into the the next language if the given language is not filled in. + class TranslationComparator(RelationshipProperty.Comparator): """ - related = self.property.mapper.class_ - cols = [getattr(related, lang) for lang in helpers.ordered_languages() if hasattr(related, lang)] - return self.has(op(func.coalesce(*cols), other, escape=escape)) - - -class TranslationExtension(AttributeExtension): - """AttributeExtension to override the behavior of .set, to accept a dict as new value.""" + RelationshipProperty.Comparator modification to enable the use of like, startswith, endswith and contains + directly on the relationship. Each of the comparators compares against the first non-null item in the list of + columns (languages) available, in order of preference (see ordered_languages() in this module). - @staticmethod - def set(state, value, oldvalue, initiator): - """Set accessor for the `Translation` object. - - :note: The value is copied using dict to avoid 2 objects - referring to the same Translation. Also the oldvalue should - wipe the values for the languages that are not in the value. - - :param state: SQLAlchemy instance state. - :param value: The value that is being assigned. - :param oldvalue: The current value. - :param initiator: SQLAlchemy initiator (accessor). + Raises NotImplementedError exception when using not with like, contains or startswith. + All the `like` operations will look into next language if the specified language is not filled in. """ - if value is None: - return None - - if isinstance(value, dict): - value_dict = value - else: - value_dict = value.get_dict() - - if oldvalue is None: - return Translation(**value_dict) - else: - for lang in helpers.ordered_languages(): - setattr(oldvalue, lang, value_dict.get(lang)) - - return oldvalue - - -def i18n_column(*args, **kwargs): - """Create Column which is a ForeignKey to Translation class generated during initialization of the package. - :param *args, **kwargs: parameters normally passed to SQLAlchemy `Column` - return: `Column` - """ - kw = dict(index=True, nullable=False, unique=True) - kw.update(**kwargs) - return Column(Integer, ForeignKey(Translation.id), *args, **kw) + LIKE_OPS = set([ + oper.like_op, oper.contains_op, oper.startswith_op, + oper.endswith_op]) + + # Only the operators in LIKE_OPS are allowed on this relationship + def operate(self, op, *other, **kw): + if op in self.LIKE_OPS: + return self._do_compare(op, *other, **kw) + else: + raise NotImplementedError() + + # contains is redefined in RelationshipProperty.Comparator, so we need to override it + def contains(self, other, escape=None): + return self.operate(oper.contains_op, other, escape=escape) + + def _do_compare(self, op, other, escape): + """Perform coalesced comparison operations to the columns of Translation model. + Looking into the the next language if the given language is not filled in. + """ + related = self.property.mapper.class_ + cols = [getattr(related, lang) for lang in helpers.get_ordered_languages() if hasattr(related, lang)] + return self.has(op(func.coalesce(*cols), other, escape=escape)) + + class TranslationExtension(AttributeExtension): + """AttributeExtension to override the behavior of .set, to accept a dict as new value.""" + + @staticmethod + def set(state, value, oldvalue, initiator): + """Set accessor for the `Translation` object. + + :note: The value is copied using dict to avoid 2 objects + referring to the same Translation. Also the oldvalue should + wipe the values for the languages that are not in the value. + + :param state: SQLAlchemy instance state. + :param value: The value that is being assigned. + :param oldvalue: The current value. + :param initiator: SQLAlchemy initiator (accessor). + """ + if value is None: + return None + + if isinstance(value, dict): + value_dict = value + else: + value_dict = value.get_dict() + + if oldvalue is None: + return Translation(**value_dict) + else: + for lang in helpers.get_ordered_languages(): + setattr(oldvalue, lang, value_dict.get(lang)) + + return oldvalue + + def i18n_column(*args, **kwargs): + """Create Column which is a ForeignKey to Translation class generated during initialization of the package. + :param *args, **kwargs: parameters normally passed to SQLAlchemy `Column` + return: `Column` + """ + kw = dict(index=True, nullable=False, unique=True) + kw.update(**kwargs) + return Column(Integer, ForeignKey(Translation.id), *args, **kw) + + def i18n_relation(column=None, comparator_factory=TranslationComparator, + extension=TranslationExtension, lazy=True, **kwargs): + """Convenience function for a relationship to i18n.Translation. + + :param column: The column that stores ID of localized text, + which should be result of the ``i18n_column``. + You need to provide it if there is more than one + localized field in the model class + """ + if column is not None: + if 'primaryjoin' in kwargs: + raise ArgumentError( + "You cannot supply 'primaryjoin' argument to 'i18n_relation'") + kwargs['primaryjoin'] = column == Translation.id -def i18n_relation(column=None, comparator_factory=TranslationComparator, - extension=TranslationExtension, lazy=True, **kwargs): - """Convenience function for a relationship to i18n.Translation. + return relationship( + Translation, comparator_factory=comparator_factory, + extension=extension, lazy=lazy, **kwargs) - :param column: The column that stores ID of localized text, - which should be result of the ``i18n_column``. - You need to provide it if there is more than one - localized field in the model class - """ - if column is not None: - if 'primaryjoin' in kwargs: - raise ArgumentError( - "You cannot supply 'primaryjoin' argument to 'i18n_relation'") - kwargs['primaryjoin'] = column == Translation.id - - return relationship( - Translation, comparator_factory=comparator_factory, - extension=extension, lazy=lazy, **kwargs) + return Attributes(Translation=Translation, i18n_column=i18n_column, i18n_relation=i18n_relation)