├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── lib64 ├── mongoframes ├── __init__.py ├── factory │ ├── __init__.py │ ├── blueprints.py │ ├── makers │ │ ├── __init__.py │ │ ├── dates.py │ │ ├── images.py │ │ ├── numbers.py │ │ ├── selections.py │ │ └── text.py │ └── quotas.py ├── frames.py ├── pagination.py └── queries.py ├── performance ├── requirements.txt └── tests │ ├── __init__.py │ ├── run_mongoengine.py │ └── run_mongoframes.py ├── setup.cfg ├── setup.py ├── snippets ├── comparable.py ├── frameless.py ├── publishing.py └── validated.py ├── tests ├── __init__.py ├── factory │ ├── __init__.py │ ├── data │ │ └── markov.txt │ ├── makers │ │ ├── __init__.py │ │ ├── test_dates.py │ │ ├── test_images.py │ │ ├── test_makers.py │ │ ├── test_numbers.py │ │ ├── test_selections.py │ │ └── test_text.py │ ├── test_blueprints.py │ ├── test_factory.py │ └── test_quotas.py ├── fixtures.py ├── test_frames.py ├── test_pagination.py └── test_queries.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # general things to ignore 2 | build/ 3 | bin/ 4 | lib/ 5 | share/ 6 | dist/ 7 | venv/ 8 | .tox/ 9 | *.egg-info/ 10 | *.egg 11 | *.py[cod] 12 | __pycache__/ 13 | *.so 14 | *~ 15 | pyvenv.cfg 16 | .pypirc 17 | 18 | # due to using tox and pytest 19 | .tox 20 | .cache 21 | .pytest_cache/ 22 | .pip-selfcheck.json 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | services: 2 | - mongodb 3 | language: python 4 | python: 5 | - "3.4" 6 | - "3.5" 7 | - "3.5-dev" # 3.5 development branch 8 | #- "nightly" # currently points to 3.6-dev 9 | # command to install dependencies 10 | install: 11 | - "pip install -e ." 12 | - "pip install pytest-mock" 13 | # command to run tests 14 | script: py.test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Getme Limited (http://getme.co.uk) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Include the license file 2 | include LICENSE 3 | include README.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | MongoFrames logo 2 | 3 | # MongoFrames 4 | 5 | [![Build Status](https://travis-ci.org/GetmeUK/MongoFrames.svg?branch=master)](https://travis-ci.org/GetmeUK/MongoFrames) 6 | [![Join the chat at https://gitter.im/GetmeUK/ContentTools](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/GetmeUK/MongoFrames?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 7 | 8 | MongoFrames is a fast unobtrusive MongoDB ODM for Python designed to fit into a workflow not dictate one. Documentation is available at [MongoFrames.com](http://mongoframes.com) and includes: 9 | 10 | - [A getting started guide](http://mongoframes.com/getting-started) 11 | - [Tutorials/Snippets](http://mongoframes.com/snippets) 12 | - [API documentation](http://mongoframes.com/api) 13 | 14 | ## Installation 15 | 16 | We recommend you use [virtualenv](https://virtualenv.pypa.io) or [virtualenvwrapper](https://virtualenvwrapper.readthedocs.io) to create a virtual environment for your install. There are several options for installing MongoFrames: 17 | 18 | - `pip install MongoFrames` *(recommended)* 19 | - `easy_install MongoFrames` 20 | - Download the source and run `python setup.py install` 21 | 22 | ## Dependencies 23 | 24 | - [blinker](https://pythonhosted.org/blinker/) >= 1.4 25 | - [pymongo](https://api.mongodb.com) >= 3 26 | - [pytest](http://pytest.org/) >= 2.8.7 *(only for testing)* 27 | 28 | ## 10 second example 29 | 30 | ```Python 31 | from mongoframes import * 32 | 33 | # Define some document frames (a.k.a models) 34 | class Dragon(Frame): 35 | _fields = {'name', 'loot'} 36 | 37 | class Item(Frame): 38 | _fields = {'desc', 'value'} 39 | 40 | # Create a dragon and loot to boot 41 | Item(desc='Sock', value=1).insert() 42 | Item(desc='Diamond', value=100).insert() 43 | Dragon(name='Burt', loot=Item.many()).insert() 44 | 45 | # Have Burt boast about his loot 46 | burt = Dragon.one(Q.name == 'Burt', projection={'loot': {'$ref': Item}}) 47 | for item in burt.loot: 48 | print('I have a {0.name} worth {0.value} crown'.format(item)) 49 | ``` 50 | 51 | ## Testing 52 | 53 | To test the library you'll need to be running a local instance of MongoDB on the standard port. 54 | 55 | To run the test suite: `py.test` 56 | To run the test suite on each supported version of Python: `tox` 57 | 58 | ## Helpful organizations 59 | MongoFrames is developed using a number of tools & services provided for free by nice folks at organizations committed to supporting open-source projects including [GitHub](https://github.com) and [Travis CI](https://travis-ci.org). 60 | -------------------------------------------------------------------------------- /lib64: -------------------------------------------------------------------------------- 1 | lib -------------------------------------------------------------------------------- /mongoframes/__init__.py: -------------------------------------------------------------------------------- 1 | from mongoframes.frames import * 2 | from mongoframes.pagination import * 3 | from mongoframes.queries import * 4 | from pymongo.collation import Collation 5 | from pymongo import ( 6 | IndexModel, 7 | ASCENDING as ASC, 8 | DESCENDING as DESC, 9 | GEOSPHERE, 10 | TEXT 11 | ) -------------------------------------------------------------------------------- /mongoframes/factory/__init__.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | __all__ = ['Factory'] 4 | 5 | 6 | class Factory: 7 | """ 8 | The `Factory` class is responsible for production of fake data for. 9 | Production of fake data is a two stage process: 10 | 11 | Assembly (see `assemble`) 12 | : A `Quota` of documents is assembled based on a `Blueprint`. 13 | 14 | At this stage the documents contain a mixture of static and dynamic 15 | data. Dynamic data is data that will be transformed during population, 16 | for example a field might contain a value of `'now,tomorrow'` which on 17 | population will be converted to a date/time between now and tomorrow. 18 | 19 | Once assembled the generated documents are returned as a list and can be 20 | either used immediately to populate the database or saved out as a 21 | template for populating the database in future (for example when 22 | building a set of test data). 23 | 24 | Finishing and population (see `finish` and `populate`) 25 | : A database is populated based on a `Blueprint` and pre-assembled list of 26 | documents. 27 | 28 | During this stage dynamic data is converted to static data (this process 29 | is call finishing) and inserted into the database. 30 | 31 | Before populate inserts the finished documents into the database it 32 | converts each document into a Frame instance and calls the on_fake 33 | method against the Blueprint, after the documents are inserted into the 34 | database it calls the on_faked method against the Blueprint. 35 | """ 36 | 37 | # Public methods 38 | 39 | def assemble(self, blueprint, quota): 40 | """Assemble a quota of documents""" 41 | 42 | # Reset the blueprint 43 | blueprint.reset() 44 | 45 | # Assemble the documents 46 | documents = [] 47 | for i in range(0, int(quota)): 48 | documents.append(blueprint.assemble()) 49 | 50 | return documents 51 | 52 | def finish(self, blueprint, documents): 53 | """Finish a list of pre-assembled documents""" 54 | 55 | # Reset the blueprint 56 | blueprint.reset() 57 | 58 | # Finish the documents 59 | finished = [] 60 | for document in documents: 61 | finished.append(blueprint.finish(document)) 62 | 63 | return finished 64 | 65 | def populate(self, blueprint, documents): 66 | """Populate the database with documents""" 67 | 68 | # Finish the documents 69 | documents = self.finish(blueprint, documents) 70 | 71 | # Convert the documents to frame instances 72 | frames = [] 73 | for document in documents: 74 | # Separate out any meta fields 75 | meta_document = {} 76 | for field_name in blueprint._meta_fields: 77 | meta_document[field_name] = document[field_name] 78 | document.pop(field_name) 79 | 80 | # Initialize the frame 81 | frame = blueprint.get_frame_cls()(document) 82 | 83 | # Apply any meta fields 84 | for key, value in meta_document.items(): 85 | setattr(frame, key, value) 86 | 87 | frames.append(frame) 88 | 89 | # Insert the documents 90 | blueprint.on_fake(frames) 91 | frames = blueprint.get_frame_cls().insert_many(frames) 92 | blueprint.on_faked(frames) 93 | 94 | return frames 95 | 96 | def reassemble(self, blueprint, fields, documents): 97 | """ 98 | Reassemble the given set of fields for a list of pre-assembed documents. 99 | 100 | NOTE: Reassembly is done in place, since the data you send the method 101 | should be JSON type safe, if you need to retain the existing document 102 | it is recommended that you copy them using `copy.deepcopy`. 103 | """ 104 | 105 | # Reset the blueprint 106 | blueprint.reset() 107 | 108 | # Reassemble the documents 109 | for document in documents: 110 | blueprint.reassemble(fields, document) -------------------------------------------------------------------------------- /mongoframes/factory/blueprints.py: -------------------------------------------------------------------------------- 1 | from blinker import signal 2 | from collections import OrderedDict 3 | 4 | from mongoframes.factory.makers import Maker 5 | 6 | __all__ = ['Blueprint'] 7 | 8 | 9 | class _BlueprintMeta(type): 10 | """ 11 | Meta class for `Frame`s to ensure an `_id` is present in any defined set of 12 | fields. 13 | """ 14 | 15 | def __new__(meta, name, bases, dct): 16 | 17 | # Collect all instructions for the blueprint. Each instruction is stored 18 | # as a key value pair in a dictionary where the name represents the 19 | # field the instruction refers to and the value is a `Maker` type for 20 | # generating a value for that field. 21 | dct['_instructions'] = dct.get('_instructions') or {} 22 | 23 | # Ensure the instructions are an ordered dictionary 24 | dct['_instructions'] = OrderedDict(dct['_instructions']) 25 | 26 | for k, v in dct.items(): 27 | if isinstance(v, Maker): 28 | dct['_instructions'][k] = v 29 | 30 | # Check the blueprint has a frame class associated with it. The `Frame` 31 | # class will be used to insert the document into the database. 32 | assert '_frame_cls' in dct or len(bases) == 0, \ 33 | 'No `_frame_cls` defined for the blueprint' 34 | 35 | # Check for a set of meta fields or define an empty set if there isn't 36 | # one. Meta-fields determine a set of one or more fields that should be 37 | # set when generating a document but which are not defined in the 38 | # assoicated `Frame` classes `_fields` attribute. 39 | if '_meta_fields' not in dct: 40 | dct['_meta_fields'] = set([]) 41 | 42 | return super(_BlueprintMeta, meta).__new__(meta, name, bases, dct) 43 | 44 | 45 | class Blueprint(metaclass=_BlueprintMeta): 46 | """ 47 | Blueprints provide the instructions for producing a fake document for a 48 | collection represented by a `Frame` class. 49 | """ 50 | 51 | def __init__(self): 52 | assert False, \ 53 | 'Blueprint classes should remain static and not be initialized' 54 | 55 | # Public methods 56 | 57 | @classmethod 58 | def get_frame_cls(cls): 59 | """Return the `Frame` class for the blueprint""" 60 | return cls._frame_cls 61 | 62 | @classmethod 63 | def get_instructions(cls): 64 | """Return the instructions for the blueprint""" 65 | return dict(cls._instructions) 66 | 67 | @classmethod 68 | def get_meta_fields(cls): 69 | """Return the meta-fields for the blueprint""" 70 | return cls._meta_fields 71 | 72 | # Factory methods 73 | 74 | @classmethod 75 | def assemble(cls): 76 | """Assemble a single document using the blueprint""" 77 | document = {} 78 | for field_name, maker in cls._instructions.items(): 79 | with maker.target(document): 80 | document[field_name] = maker() 81 | return document 82 | 83 | @classmethod 84 | def finish(cls, document): 85 | """ 86 | Take a assembled document and convert all assembled values to 87 | finished values. 88 | """ 89 | target_document = {} 90 | document_copy = {} 91 | for field_name, value in document.items(): 92 | maker = cls._instructions[field_name] 93 | target_document = document.copy() 94 | with maker.target(target_document): 95 | document_copy[field_name] = maker(value) 96 | target_document[field_name] = document_copy[field_name] 97 | return document_copy 98 | 99 | @classmethod 100 | def reassemble(cls, fields, document): 101 | """ 102 | Take a previously assembled document and reassemble the given set of 103 | fields for it in place. 104 | """ 105 | for field_name in cls._instructions: 106 | if field_name in fields: 107 | maker = cls._instructions[field_name] 108 | with maker.target(document): 109 | document[field_name] = maker() 110 | 111 | @classmethod 112 | def reset(cls): 113 | """ 114 | Reset the blueprint. Blueprints are typically reset before being used to 115 | assemble a quota of documents. Resetting a Blueprint will in turn reset 116 | all the Maker instances defined as instructions for the Blueprint 117 | allowing internal counters and alike to be reset. 118 | """ 119 | # Reset instructions 120 | for maker in cls._instructions.values(): 121 | maker.reset() 122 | 123 | # Events 124 | 125 | @classmethod 126 | def on_fake(cls, frames): 127 | """Called before frames are inserted""" 128 | 129 | # By default the hook will simply trigger a `fake` event against the 130 | # frame. This allows the overriding method to control this behaviour. 131 | signal('fake').send(cls._frame_cls, frames=frames) 132 | 133 | @classmethod 134 | def on_faked(cls, frames): 135 | """Called after frames are inserted""" 136 | 137 | # By default the hook will simply trigger a `fake` event against the 138 | # frame. This allows the overriding method to control this behaviour. 139 | signal('faked').send(cls._frame_cls, frames=frames) -------------------------------------------------------------------------------- /mongoframes/factory/makers/__init__.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import random 3 | 4 | import faker 5 | 6 | from mongoframes.queries import Q 7 | 8 | __all__ = [ 9 | 'DictOf', 10 | 'Faker', 11 | 'Lambda', 12 | 'ListOf', 13 | 'Reference', 14 | 'Static', 15 | 'SubFactory', 16 | 'Unique' 17 | ] 18 | 19 | 20 | class Maker: 21 | """ 22 | A base class for all Maker classes. 23 | """ 24 | 25 | def __init__(self): 26 | 27 | # The document the maker is assembling/finishing data for 28 | self._document = None 29 | 30 | def __call__(self, *args): 31 | if args: 32 | return self._finish(*args) 33 | return self._assemble() 34 | 35 | @property 36 | def document(self): 37 | # Return the target document 38 | return self._document 39 | 40 | def reset(self): 41 | """Reset the maker instance""" 42 | pass 43 | 44 | def _assemble(self): 45 | return None 46 | 47 | def _finish(self, value): 48 | return value 49 | 50 | @contextlib.contextmanager 51 | def target(self, document): 52 | self._document = document 53 | yield 54 | self._document = None 55 | 56 | 57 | class DictOf(Maker): 58 | """ 59 | Make a dictionary of key/values where each value is a set value or generated 60 | using a maker. 61 | """ 62 | 63 | def __init__(self, table): 64 | super().__init__() 65 | 66 | # The table of keyword arguments that will be used to generate the 67 | # dictionary. 68 | self._table = table 69 | 70 | def _assemble(self): 71 | table = {} 72 | for k, v in self._table.items(): 73 | if isinstance(v, Maker): 74 | table[k] = v._assemble() 75 | else: 76 | table[k] = None 77 | return table 78 | 79 | def _finish(self, value): 80 | table = {} 81 | for k, v in self._table.items(): 82 | if isinstance(v, Maker): 83 | table[k] = self._table[k]._finish(value[k]) 84 | else: 85 | table[k] = self._table[k] 86 | 87 | return table 88 | 89 | 90 | class Faker(Maker): 91 | """ 92 | Use any faker provider to generate a value (see 93 | http://fake-factory.readthedocs.io/) 94 | """ 95 | 96 | default_locale = 'en_US' 97 | 98 | def __init__(self, provider, assembler=True, locale=None, **kwargs): 99 | super().__init__() 100 | 101 | # The provider that will be used to generate the value 102 | self._provider = provider 103 | 104 | # Flag indicating if the providers should be called in _assemble (True) 105 | # or _finish (False). 106 | self._assembler = assembler 107 | 108 | # The locale that will be used by the faker factory 109 | self._locale = locale or self.default_locale 110 | 111 | # The keyword arguments for the provider 112 | self._kwargs = kwargs 113 | 114 | def _assemble(self): 115 | if not self._assembler: 116 | return None 117 | provider = getattr(self.get_fake(self._locale), self._provider) 118 | return provider(**self._kwargs) 119 | 120 | def _finish(self, value): 121 | if self._assembler: 122 | return value 123 | provider = getattr(self.get_fake(self._locale), self._provider) 124 | return provider(**self._kwargs) 125 | 126 | @staticmethod 127 | def get_fake(locale=None): 128 | """Return a shared faker factory used to generate fake data""" 129 | if locale is None: 130 | locale = Faker.default_locale 131 | 132 | if not hasattr(Maker, '_fake_' + locale): 133 | Faker._fake = faker.Factory.create(locale) 134 | return Faker._fake 135 | 136 | 137 | class Lambda(Maker): 138 | """ 139 | Use a function to generate a value. 140 | """ 141 | 142 | def __init__(self, func, assembler=True, finisher=False): 143 | super().__init__() 144 | 145 | assert assembler or finisher, \ 146 | 'Either `assembler` or `finisher` must be true for lambda' 147 | 148 | # The function to call 149 | self._func = func 150 | 151 | # Flag indicating if the lambda function should be called in _assemble 152 | self._assembler = assembler 153 | 154 | # Flag indicating if the lambda function should be called in _finish 155 | self._finisher = finisher 156 | 157 | def _assemble(self): 158 | if self._assembler: 159 | return self._func(self.document) 160 | return None 161 | 162 | def _finish(self, value): 163 | if self._finisher: 164 | return self._func(self.document, value) 165 | return value 166 | 167 | 168 | class ListOf(Maker): 169 | """ 170 | Generate a list of values of the given quantity using the specified maker. 171 | """ 172 | 173 | def __init__(self, maker, quantity, reset_maker=False): 174 | super().__init__() 175 | 176 | # The maker used to generate each value in the list 177 | self._maker = maker 178 | 179 | # The number of list items to generate 180 | self._quantity = quantity 181 | 182 | # A flag indicating if the maker should be reset each time the list is 183 | # generated. 184 | self._reset_maker = reset_maker 185 | 186 | def _assemble(self): 187 | quantity = int(self._quantity) 188 | 189 | if self._reset_maker: 190 | self._maker.reset() 191 | 192 | return [self._maker() for i in range(0, quantity)] 193 | 194 | def _finish(self, value): 195 | 196 | if self._reset_maker: 197 | self._maker.reset() 198 | 199 | with self._maker.target(self.document): 200 | return [self._maker(v) for v in value] 201 | 202 | 203 | class Static(Maker): 204 | """ 205 | A maker that returns a fixed value. 206 | """ 207 | 208 | def __init__(self, value, assembler=True): 209 | super().__init__() 210 | 211 | # The value to return 212 | self._value = value 213 | 214 | # Flag indicating if the value should be return in _assemble (True) or 215 | # _finish (False). 216 | self._assembler = assembler 217 | 218 | def _assemble(self): 219 | if self._assembler: 220 | return self._value 221 | return None 222 | 223 | def _finish(self, value): 224 | if self._assembler: 225 | return value 226 | return self._value 227 | 228 | 229 | class SubFactory(Maker): 230 | """ 231 | A maker that generates sub-documents. 232 | """ 233 | 234 | def __init__(self, blueprint): 235 | super().__init__() 236 | 237 | # The blueprint to produce 238 | self._blueprint = blueprint 239 | 240 | def reset(self): 241 | """Reset the blueprint for the maker""" 242 | self._blueprint.reset() 243 | 244 | def _assemble(self): 245 | return self._blueprint.assemble() 246 | 247 | def _finish(self, value): 248 | document = self._blueprint.finish(value) 249 | 250 | # Separate out any meta fields 251 | meta_document = {} 252 | for field_name in self._blueprint._meta_fields: 253 | meta_document[field_name] = document[field_name] 254 | document.pop(field_name) 255 | 256 | # Initialize the sub-frame 257 | sub_frame = self._blueprint.get_frame_cls()(document) 258 | 259 | # Apply any meta fields 260 | for key, value in meta_document.items(): 261 | setattr(sub_frame, key, value) 262 | 263 | return sub_frame 264 | 265 | 266 | class Unique(Maker): 267 | """ 268 | Ensure that unique values are generated by a maker. 269 | """ 270 | 271 | def __init__(self, 272 | maker, 273 | exclude=None, 274 | assembler=True, 275 | max_attempts=1000 276 | ): 277 | super().__init__() 278 | 279 | # The maker that will generate values 280 | self._maker = maker 281 | 282 | # A set of existing values 283 | self._exclude = exclude or set([]) 284 | 285 | # Flag indicating if the providers should be called in _assemble (True) 286 | # or _finish (False). 287 | self._assembler = assembler 288 | 289 | # A set of used values 290 | self._used_values = set(self._exclude) 291 | 292 | # The maximum number of attempts to generate unique data that should be 293 | # performed. 294 | self._max_attempts = max_attempts 295 | 296 | def reset(self): 297 | """Reset the set of used values""" 298 | self._used_values = set(self._exclude) 299 | 300 | def _get_unique(self, *args): 301 | """Generate a unique value using the assigned maker""" 302 | 303 | # Generate a unique values 304 | value = '' 305 | attempts = 0 306 | while True: 307 | attempts += 1 308 | value = self._maker(*args) 309 | if value not in self._used_values: 310 | break 311 | 312 | assert attempts < self._max_attempts, \ 313 | 'Too many attempts to generate a unique value' 314 | 315 | # Add the value to the set of used values 316 | self._used_values.add(value) 317 | 318 | return value 319 | 320 | def _assemble(self): 321 | if not self._assembler: 322 | return self._maker() 323 | return self._get_unique() 324 | 325 | def _finish(self, value): 326 | if self._assembler: 327 | with self._maker.target(self.document): 328 | value = self._maker(value) 329 | return self._get_unique(value) -------------------------------------------------------------------------------- /mongoframes/factory/makers/dates.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import math 3 | import random 4 | import re 5 | 6 | from mongoframes.factory.makers import Maker 7 | 8 | __all__ = [ 9 | 'DateBetween' 10 | ] 11 | 12 | 13 | class DateBetween(Maker): 14 | """ 15 | Return a date between two dates. Dates can be specified either as 16 | `datetime.date` instances or as strings of the form: 17 | 18 | "{yesterday|today|tomorrow}{+|-}{no_of_days}" 19 | """ 20 | 21 | def __init__(self, min_date, max_date): 22 | super().__init__() 23 | 24 | # The date range between which a date will be selected 25 | self._min_date = min_date 26 | self._max_date = max_date 27 | 28 | def _assemble(self): 29 | min_date = self.parse_date(self._min_date) 30 | max_date = self.parse_date(self._max_date) 31 | seconds = random.randint(0, int((max_date - min_date).total_seconds())) 32 | return math.floor(seconds / 86400) 33 | 34 | def _finish(self, value): 35 | min_date = self.parse_date(self._min_date) 36 | return min_date + datetime.timedelta(seconds=value * 86400) 37 | 38 | @staticmethod 39 | def parse_date(d): 40 | if isinstance(d, datetime.date): 41 | return d 42 | 43 | # Parse the date string 44 | result = re.match( 45 | '(today|tomorrow|yesterday)((\-|\+)(\d+)){0,1}', 46 | d 47 | ) 48 | assert result, 'Not a valid date string' 49 | 50 | # Determine the base date 51 | if result.groups()[0] == 'today': 52 | d = datetime.date.today() 53 | 54 | elif result.groups()[0] == 'tomorrow': 55 | d = datetime.date.today() + datetime.timedelta(days=1) 56 | 57 | elif result.groups()[0] == 'yesterday': 58 | d = datetime.date.today() - datetime.timedelta(days=1) 59 | 60 | # Add any offset 61 | if result.groups()[1]: 62 | op = result.groups()[2] 63 | days = int(result.groups()[3]) 64 | if op == '+': 65 | d += datetime.timedelta(days=days) 66 | else: 67 | d -= datetime.timedelta(days=days) 68 | 69 | return d -------------------------------------------------------------------------------- /mongoframes/factory/makers/images.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | 3 | from mongoframes.factory.makers import Maker 4 | 5 | __all__ = [ 6 | 'ImageURL' 7 | ] 8 | 9 | 10 | class ImageURL(Maker): 11 | """ 12 | Return a fake image URL (by default we use the `fakeimg.pl` service). 13 | """ 14 | 15 | def __init__(self, 16 | width, 17 | height, 18 | background='CCCCCC', 19 | foreground='8D8D8D', 20 | options=None, 21 | service_url='//fakeimg.pl', 22 | service_formatter=None 23 | ): 24 | super().__init__() 25 | 26 | # The size of the image to generate 27 | self._width = width 28 | self._height = height 29 | 30 | # The foreground/background colours for the image 31 | self._background = background 32 | self._foreground = foreground 33 | 34 | # A dictionary of options used when generating the image 35 | self._options = options 36 | 37 | # The service URL to use when calling the service 38 | self._service_url = service_url 39 | 40 | # A formatter function that can produce an image URL for the service 41 | self._service_formatter = service_formatter or \ 42 | ImageURL._default_service_formatter 43 | 44 | def _assemble(self): 45 | return self._service_formatter( 46 | self._service_url, 47 | int(self._width), 48 | int(self._height), 49 | self._background, 50 | self._foreground, 51 | self._options 52 | ) 53 | 54 | @staticmethod 55 | def _default_service_formatter( 56 | service_url, 57 | width, 58 | height, 59 | background, 60 | foreground, 61 | options 62 | ): 63 | """Generate an image URL for a service""" 64 | 65 | # Build the base URL 66 | image_tmp = '{service_url}/{width}x{height}/{background}/{foreground}/' 67 | image_url = image_tmp.format( 68 | service_url=service_url, 69 | width=width, 70 | height=height, 71 | background=background, 72 | foreground=foreground 73 | ) 74 | 75 | # Add any options 76 | if options: 77 | image_url += '?' + urlencode(options) 78 | 79 | return image_url 80 | -------------------------------------------------------------------------------- /mongoframes/factory/makers/numbers.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from mongoframes.factory.makers import Maker 4 | 5 | __all__ = [ 6 | 'Counter' 7 | ] 8 | 9 | 10 | class Counter(Maker): 11 | """ 12 | Generate a sequence of numbers. 13 | """ 14 | 15 | def __init__(self, start_from=1, step=1): 16 | super().__init__() 17 | 18 | self._start_from = int(start_from) 19 | self._step = step 20 | self._counter = self._start_from 21 | 22 | def reset(self): 23 | self._counter = int(self._start_from) 24 | 25 | def _assemble(self): 26 | value = self._counter 27 | self._counter += int(self._step) 28 | return value 29 | 30 | 31 | class Float(Maker): 32 | """ 33 | Generate a random float between two values. 34 | """ 35 | 36 | def __init__(self, min_value, max_value): 37 | super().__init__() 38 | 39 | self._min_value = min_value 40 | self._max_value = max_value 41 | 42 | def _assemble(self): 43 | return random.uniform(float(self._min_value), float(self._max_value)) 44 | 45 | 46 | class Int(Float): 47 | """ 48 | Generate a random integer between two values. 49 | """ 50 | 51 | def _assemble(self): 52 | return random.randint(int(self._min_value), int(self._max_value)) -------------------------------------------------------------------------------- /mongoframes/factory/makers/selections.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | import itertools 3 | import math 4 | import random 5 | 6 | from mongoframes.factory.makers import Maker 7 | from mongoframes import ASC 8 | 9 | __all__ = [ 10 | 'Cycle', 11 | 'OneOf', 12 | 'RandomReference', 13 | 'SomeOf' 14 | ] 15 | 16 | 17 | class Cycle(Maker): 18 | """ 19 | Pick the next item from a list of makers and/or values cycling through the 20 | list and repeating when we reach the end. 21 | """ 22 | 23 | def __init__(self, items): 24 | super().__init__() 25 | 26 | # The list of makers/values to select from 27 | self._items = items 28 | 29 | # The index of the item that will be returned next 30 | self._item_index = 0 31 | 32 | def reset(self): 33 | """Reset the item index""" 34 | self._item_index = 0 35 | 36 | def _assemble(self): 37 | # Select the next item 38 | item_index = self._item_index 39 | item = self._items[item_index] 40 | 41 | # Move the index on 1 (and wrap it if we are at the end of the list) 42 | self._item_index += 1 43 | if self._item_index >= len(self._items): 44 | self._item_index = 0 45 | 46 | # Return the index and it's assembled value 47 | if isinstance(item, Maker): 48 | return [item_index, item._assemble()] 49 | return [item_index, None] 50 | 51 | def _finish(self, value): 52 | item = self._items[value[0]] 53 | if isinstance(item, Maker): 54 | with item.target(self.document): 55 | return item._finish(value[1]) 56 | return item 57 | 58 | 59 | class OneOf(Maker): 60 | """ 61 | Pick one item from a list of makers and/or values. 62 | """ 63 | 64 | def __init__(self, items, weights=None): 65 | super().__init__() 66 | 67 | # The list of makers/values to select from 68 | self._items = items 69 | 70 | # The weighting to apply when selecting a maker/value 71 | self._weights = weights 72 | 73 | def _assemble(self): 74 | # Select an item 75 | item_index = 0 76 | if self._weights: 77 | item_index = self.weighted(self._weights) 78 | else: 79 | item_index = random.randint(0, len(self._items) - 1) 80 | 81 | # Return the index and it's assembled value 82 | item = self._items[item_index] 83 | if isinstance(item, Maker): 84 | return [item_index, item._assemble()] 85 | return [item_index, None] 86 | 87 | def _finish(self, value): 88 | item = self._items[value[0]] 89 | if isinstance(item, Maker): 90 | with item.target(self.document): 91 | return item._finish(value[1]) 92 | return item 93 | 94 | @staticmethod 95 | def weighted(weights): 96 | """ 97 | Return a random integer 0 <= N <= len(weights) - 1, where the weights 98 | determine the probability of each possible integer. 99 | 100 | Based on this StackOverflow post by Ned Batchelder: 101 | 102 | http://stackoverflow.com/questions/3679694/a-weighted-version-of-random-choice 103 | """ 104 | 105 | # Convert weights to floats 106 | weights = [float(w) for w in weights] 107 | 108 | # Pick a value at random 109 | choice = random.uniform(0, sum(weights)) 110 | 111 | # Find the value 112 | position = 0 113 | for i, weight in enumerate(weights): 114 | if position + weight >= choice: 115 | return i 116 | position += weight 117 | 118 | 119 | class RandomReference(Maker): 120 | """ 121 | Pick a reference document at random from a collection (determined by the 122 | given frame_cls) optionally applying a constraint. 123 | """ 124 | 125 | def __init__(self, frame_cls, constraint=None): 126 | super().__init__() 127 | 128 | # The frame class that will be used to select a reference with 129 | self._frame_cls = frame_cls 130 | 131 | # The constraint applied when select a reference document 132 | self._constraint = constraint or {} 133 | 134 | def _assemble(self): 135 | # Select a random float that will be used to select a reference document 136 | # by it's position. 137 | return random.random() 138 | 139 | def _finish(self, value): 140 | # Count the number of documents available to pick from 141 | total_documents = self._frame_cls.count(self._constraint) 142 | 143 | # Calculate the position of the document we've picked 144 | position = math.floor(total_documents * value) 145 | 146 | # Select the document 147 | document = self._frame_cls.one( 148 | self._constraint, 149 | limit=1, 150 | skip=position, 151 | sort=[('_id', ASC)], 152 | projection={'_id': True} 153 | ) 154 | 155 | # Check the document was found 156 | if document: 157 | return document._id 158 | 159 | return None 160 | 161 | 162 | class SomeOf(Maker): 163 | """ 164 | Pick one or more items from a list of makers and/or values. 165 | """ 166 | 167 | def __init__( 168 | self, 169 | items, 170 | sample_size, 171 | weights=None, 172 | with_replacement=False 173 | ): 174 | super().__init__() 175 | 176 | # The list of makers/values to select from 177 | self._items = items 178 | 179 | # The number of items to pick 180 | self._sample_size = sample_size 181 | 182 | # The weighting to apply when selecting a maker/value 183 | self._weights = weights 184 | 185 | # A flag indicating if the same item can be selected from the list more 186 | # than once. 187 | self._with_replacement = with_replacement 188 | 189 | def _assemble(self): 190 | # Select some items 191 | sample_size = int(self._sample_size) 192 | 193 | sample_indexes = [] 194 | if self._weights: 195 | sample_indexes = self.weighted( 196 | self._weights, 197 | sample_size, 198 | with_replacement=self._with_replacement 199 | ) 200 | else: 201 | sample_range = range(0, len(self._items)) 202 | if self._with_replacement: 203 | sample_indexes = [random.choice(sample_range) \ 204 | for s in range(0, sample_size)] 205 | else: 206 | sample_indexes = random.sample(sample_range, sample_size) 207 | 208 | # Return the sampled indexes and their assembled values 209 | values = [] 210 | for sample_index in sample_indexes: 211 | item = self._items[sample_index] 212 | if isinstance(item, Maker): 213 | values.append([sample_index, item._assemble()]) 214 | else: 215 | values.append([sample_index, None]) 216 | 217 | return values 218 | 219 | def _finish(self, value): 220 | values = [] 221 | for sample in value: 222 | item = self._items[sample[0]] 223 | if isinstance(item, Maker): 224 | with item.target(self.document): 225 | values.append(item._finish(sample[1])) 226 | else: 227 | values.append(item) 228 | return values 229 | 230 | @staticmethod 231 | def p(i, sample_size, weights): 232 | """ 233 | Given a weighted set and sample size return the probabilty that the 234 | weight `i` will be present in the sample. 235 | 236 | Created to test the output of the `SomeOf` maker class. The math was 237 | provided by Andy Blackshaw - thank you dad :) 238 | """ 239 | 240 | # Determine the initial pick values 241 | weight_i = weights[i] 242 | weights_sum = sum(weights) 243 | 244 | # Build a list of weights that don't contain the weight `i`. This list will 245 | # be used to build the possible picks before weight `i`. 246 | other_weights = list(weights) 247 | del other_weights[i] 248 | 249 | # Calculate the probability 250 | probability_of_i = 0 251 | for picks in range(0, sample_size): 252 | 253 | # Build the list of possible permutations for this pick in the sample 254 | permutations = list(itertools.permutations(other_weights, picks)) 255 | 256 | # Calculate the probability for this permutation 257 | permutation_probabilities = [] 258 | for permutation in permutations: 259 | 260 | # Calculate the probability for each pick in the permutation 261 | pick_probabilities = [] 262 | pick_weight_sum = weights_sum 263 | 264 | for pick in permutation: 265 | pick_probabilities.append(pick / pick_weight_sum) 266 | 267 | # Each time we pick we update the sum of the weight the next 268 | # pick is from. 269 | pick_weight_sum -= pick 270 | 271 | # Add the probability of picking i as the last pick 272 | pick_probabilities += [weight_i / pick_weight_sum] 273 | 274 | # Multiply all the probabilities for the permutation together 275 | permutation_probability = reduce( 276 | lambda x, y: x * y, pick_probabilities 277 | ) 278 | permutation_probabilities.append(permutation_probability) 279 | 280 | # Add together all the probabilities for all permutations together 281 | probability_of_i += sum(permutation_probabilities) 282 | 283 | return probability_of_i 284 | 285 | @staticmethod 286 | def weighted(weights, sample_size, with_replacement=False): 287 | """ 288 | Return a set of random integers 0 <= N <= len(weights) - 1, where the 289 | weights determine the probability of each possible integer in the set. 290 | """ 291 | assert sample_size <= len(weights), "The sample size must be smaller \ 292 | than or equal to the number of weights it's taken from." 293 | 294 | # Convert weights to floats 295 | weights = [float(w) for w in weights] 296 | weight_indexes = list(range(0, len(weights))) 297 | 298 | samples = [] 299 | while len(samples) < sample_size: 300 | # Choice a weight 301 | sample = OneOf.weighted(weights) 302 | 303 | # Add the choosen weight to our samples 304 | samples.append(weight_indexes[sample]) 305 | 306 | if not with_replacement: 307 | # Remove the weight from the list of weights we can select from 308 | del weights[sample] 309 | del weight_indexes[sample] 310 | 311 | return samples 312 | 313 | 314 | -------------------------------------------------------------------------------- /mongoframes/factory/makers/text.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | import string 4 | 5 | from mongoframes.factory.makers import Faker, Maker 6 | 7 | __all__ = [ 8 | 'Code', 9 | 'Join', 10 | 'Lorem', 11 | 'Markov' 12 | 'Sequence' 13 | ] 14 | 15 | 16 | class Code(Maker): 17 | """ 18 | Generate a random code of the given length using either the default_charset 19 | or optionally a custom charset. 20 | """ 21 | 22 | # The default character set that codes are made from 23 | default_charset = string.ascii_uppercase + string.digits 24 | 25 | def __init__(self, length, charset=None): 26 | super().__init__() 27 | 28 | # If no charset is provided use the default 29 | if not charset: 30 | charset = self.default_charset 31 | 32 | # The length of the code that will be generated 33 | self._length = length 34 | 35 | # The character set the code will be made from 36 | self._charset = list(charset) 37 | 38 | def _assemble(self): 39 | return ''.join([ 40 | random.choice(self._charset) for i in range(0, int(self._length)) 41 | ]) 42 | 43 | 44 | class Join(Maker): 45 | """ 46 | Join the output of 2 or more items (makers and/or values) together with a 47 | seperator string. 48 | """ 49 | 50 | def __init__(self, items, sep=' '): 51 | super().__init__() 52 | 53 | # The list of makers/values that will be joined 54 | self._items = items 55 | 56 | # The string used to join the items together 57 | self._sep = sep 58 | 59 | def _assemble(self): 60 | values = [] 61 | for item in self._items: 62 | if isinstance(item, Maker): 63 | values.append(item._assemble()) 64 | else: 65 | values.append(item) 66 | return values 67 | 68 | def _finish(self, value): 69 | parts = [] 70 | for i, item in enumerate(self._items): 71 | if isinstance(item, Maker): 72 | parts.append(str(item._finish(value[i]))) 73 | else: 74 | parts.append(str(item)) 75 | return self._sep.join(parts) 76 | 77 | 78 | class Lorem(Maker): 79 | """ 80 | Generate random amounts of lorem ipsum. 81 | 82 | To determine the amount of text generated the type of text structure to 83 | generate must be specified; 84 | 85 | - body, 86 | - paragraph, 87 | - sentence 88 | 89 | along with the quantity; 90 | 91 | - paragraphs in a body, 92 | - sentences in a paragraph, 93 | - words in a scentence. 94 | """ 95 | 96 | def __init__(self, text_type, quantity): 97 | super().__init__() 98 | 99 | # The type of text structure to generate 100 | self._text_type = text_type 101 | 102 | # The quantity of text to generate 103 | self._quantity = quantity 104 | 105 | assert self._text_type in ['body', 'paragraph', 'sentence'], \ 106 | 'Not a supported text type' 107 | 108 | def _assemble(self): 109 | quantity = int(self._quantity) 110 | 111 | if self._text_type == 'body': 112 | return '\n'.join(Faker.get_fake().paragraphs(nb=quantity)) 113 | 114 | if self._text_type == 'paragraph': 115 | return Faker.get_fake().paragraph( 116 | nb_sentences=quantity, 117 | variable_nb_sentences=False 118 | ) 119 | 120 | if self._text_type == 'sentence': 121 | return Faker.get_fake().sentence( 122 | nb_words=quantity, 123 | variable_nb_words=False 124 | ) 125 | 126 | 127 | class Markov(Maker): 128 | """ 129 | Generate random amounts of text using a Markov chain. 130 | 131 | To determine the amount of text generated the type of text structure to 132 | generate must be specified; 133 | 134 | - body, 135 | - paragraph, 136 | - sentence 137 | 138 | along with the quantity; 139 | 140 | - paragraphs in a body, 141 | - sentences in a paragraph, 142 | - words in a sentence. 143 | 144 | This code is heavily based on (lifted from) the code presented in this 145 | article by Shabda Raaj: 146 | http://agiliq.com/blog/2009/06/generating-pseudo-random-text-with-markov-chains-u/ 147 | """ 148 | 149 | # FIXME: 150 | # w1, w2 = w2, random.choice(db['freqs'][(w1, w2)]) 151 | # KeyError: ('summer...."\n\nTHE', 'END') 152 | 153 | _dbs = {} 154 | 155 | def __init__(self, db, text_type, quantity): 156 | super().__init__() 157 | 158 | # The database to generate the text from 159 | self._db = db 160 | 161 | assert db in self.__class__._dbs, 'Word database does not exist' 162 | 163 | # The type of text structure to generate 164 | self._text_type = text_type 165 | 166 | # The quantity of text to generate 167 | self._quantity = quantity 168 | 169 | assert self._text_type in ['body', 'paragraph', 'sentence'], \ 170 | 'Not a supported text type' 171 | 172 | # Public methds 173 | 174 | @property 175 | def database(self): 176 | """Return the selected word database""" 177 | return self.__class__._dbs[self._db] 178 | 179 | # Private methods 180 | 181 | def _assemble(self): 182 | quantity = int(self._quantity) 183 | 184 | if self._text_type == 'body': 185 | return self._body(quantity) 186 | 187 | if self._text_type == 'paragraph': 188 | return self._paragraph(quantity) 189 | 190 | if self._text_type == 'sentence': 191 | return self._sentence(quantity) 192 | 193 | def _body(self, paragraphs): 194 | """Generate a body of text""" 195 | body = [] 196 | for i in range(paragraphs): 197 | paragraph = self._paragraph(random.randint(1, 10)) 198 | body.append(paragraph) 199 | 200 | return '\n'.join(body) 201 | 202 | def _paragraph(self, sentences): 203 | """Generate a paragraph""" 204 | paragraph = [] 205 | for i in range(sentences): 206 | sentence = self._sentence(random.randint(5, 16)) 207 | paragraph.append(sentence) 208 | 209 | return ' '.join(paragraph) 210 | 211 | def _sentence(self, words): 212 | """Generate a sentence""" 213 | db = self.database 214 | 215 | # Generate 2 words to start a sentence with 216 | seed = random.randint(0, db['word_count'] - 3) 217 | seed_word, next_word = db['words'][seed], db['words'][seed + 1] 218 | w1, w2 = seed_word, next_word 219 | 220 | # Generate the complete sentence 221 | sentence = [] 222 | for i in range(0, words - 1): 223 | sentence.append(w1) 224 | w1, w2 = w2, random.choice(db['freqs'][(w1, w2)]) 225 | sentence.append(w2) 226 | 227 | # Make the sentence respectable 228 | sentence = ' '.join(sentence) 229 | 230 | # Capitalize the sentence 231 | sentence = sentence.capitalize() 232 | 233 | # Remove additional sentence ending puntuation 234 | sentence = sentence.replace('.', '') 235 | sentence = sentence.replace('!', '') 236 | sentence = sentence.replace('?', '') 237 | sentence = sentence.replace(':', '') 238 | 239 | # Remove quote tags 240 | sentence = sentence.replace('.', '') 241 | sentence = sentence.replace('!', '') 242 | sentence = sentence.replace('?', '') 243 | sentence = sentence.replace(':', '') 244 | sentence = sentence.replace('"', '') 245 | 246 | # If the last character is not an alphanumeric remove it 247 | sentence = re.sub('[^a-zA-Z0-9]$', '', sentence) 248 | 249 | # Remove excess space 250 | sentence = re.sub('\s+', ' ', sentence) 251 | 252 | # Add a full stop 253 | sentence += '.' 254 | 255 | return sentence 256 | 257 | @classmethod 258 | def init_word_db(cls, name, text): 259 | """Initialize a database of words for the maker with the given name""" 260 | # Prep the words 261 | text = text.replace('\n', ' ').replace('\r', ' ') 262 | words = [w.strip() for w in text.split(' ') if w.strip()] 263 | 264 | assert len(words) > 2, \ 265 | 'Database text sources must contain 3 or more words.' 266 | 267 | # Build the database 268 | freqs = {} 269 | for i in range(len(words) - 2): 270 | 271 | # Create a triplet from the current word 272 | w1 = words[i] 273 | w2 = words[i + 1] 274 | w3 = words[i + 2] 275 | 276 | # Add the triplet to the database 277 | key = (w1, w2) 278 | if key in freqs: 279 | freqs[key].append(w3) 280 | else: 281 | freqs[key] = [w3] 282 | 283 | # Store the database so it can be used 284 | cls._dbs[name] = { 285 | 'freqs': freqs, 286 | 'words': words, 287 | 'word_count': len(words) - 2 288 | } 289 | 290 | 291 | class Sequence(Maker): 292 | """ 293 | Generate a sequence of values where a number is inserted into a template. 294 | The template should specify an index value, for example: 295 | 296 | "prefix-{index}" 297 | """ 298 | 299 | def __init__(self, template, start_from=1): 300 | super().__init__() 301 | 302 | self._template = template 303 | self._start_from = start_from 304 | self._index = start_from 305 | 306 | def reset(self): 307 | self._index = self._start_from 308 | 309 | def _assemble(self): 310 | value = self._template.format(index=self._index) 311 | self._index += 1 312 | return value -------------------------------------------------------------------------------- /mongoframes/factory/quotas.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | __all__ = [ 4 | 'Gauss', 5 | 'Random' 6 | ] 7 | 8 | 9 | class Quota: 10 | """ 11 | A base class for implementing variable quota (although the base class 12 | provides a fixed value and is no different than using an integer or float 13 | value). 14 | 15 | The Quota class can be safely used as an argument for `Factory`s and 16 | `Maker`s. 17 | """ 18 | 19 | def __init__(self, quantity): 20 | self._quantity = quantity 21 | 22 | def __int__(self): 23 | return int(self._quantity) 24 | 25 | def __float__(self): 26 | return float(self._quantity) 27 | 28 | 29 | class Gauss(Quota): 30 | """ 31 | Return a random quota using a Gaussian distribution. 32 | """ 33 | 34 | def __init__(self, mu, sigma): 35 | self._mu = mu 36 | self._sigma = sigma 37 | 38 | def __int__(self): 39 | return int(float(self)) 40 | 41 | def __float__(self): 42 | return random.gauss(self._mu, self._sigma) 43 | 44 | 45 | class Random(Quota): 46 | """ 47 | Return a random quota between two values. 48 | """ 49 | 50 | def __init__(self, min_quantity, max_quantity): 51 | self._min_quantity = min_quantity 52 | self._max_quantity = max_quantity 53 | 54 | def __int__(self): 55 | return random.randint(self._min_quantity, self._max_quantity) 56 | 57 | def __float__(self): 58 | return random.uniform(self._min_quantity, self._max_quantity) -------------------------------------------------------------------------------- /mongoframes/pagination.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for paginating frames. 3 | """ 4 | 5 | from copy import deepcopy 6 | import math 7 | 8 | from mongoframes.queries import Condition, Group, to_refs 9 | 10 | 11 | __all__ = ( 12 | # Exceptions 13 | 'InvalidPage', 14 | 15 | # Classes 16 | 'Page', 17 | 'Paginator' 18 | ) 19 | 20 | 21 | class InvalidPage(Exception): 22 | """ 23 | An error raised when an invalid page is requested. 24 | """ 25 | 26 | 27 | class Page(object): 28 | """ 29 | A class to represent one page of results. 30 | """ 31 | 32 | def __init__(self, offset, number, items, next, prev): 33 | 34 | # The offset of the first result in the page from the first result of 35 | # the entire selection. 36 | self._offset = offset 37 | 38 | # The page number 39 | self._number = number 40 | 41 | # The results/frames for this page 42 | self._items = list(items) 43 | 44 | # The next and previous page numbers (if there is no next/previous page 45 | # the value will be None. 46 | self._next = next 47 | self._prev = prev 48 | 49 | def __getitem__(self, i): 50 | return self._items[i] 51 | 52 | def __iter__(self): 53 | for item in self._items: 54 | yield item 55 | 56 | def __len__(self): 57 | return len(self._items) 58 | 59 | # Read-only properties 60 | 61 | @property 62 | def items(self): 63 | """Return a list of results for the page""" 64 | return self._items 65 | 66 | @property 67 | def next(self): 68 | """ 69 | Return the page number for the next page or None if there isn't one. 70 | """ 71 | return self._next 72 | 73 | @property 74 | def number(self): 75 | """Return the page number""" 76 | return self._number 77 | 78 | @property 79 | def prev(self): 80 | """ 81 | Return the page number for the previous page or None if there isn't one. 82 | """ 83 | return self._prev 84 | 85 | # Public methods 86 | 87 | def offset(self, item): 88 | """Return the offset for an item in the page""" 89 | return self._offset + self.items.index(item) 90 | 91 | 92 | class Paginator(object): 93 | """ 94 | A pagination class for slicing query results into pages. This class is 95 | designed to work with Frame classes. 96 | """ 97 | 98 | def __init__( 99 | self, 100 | frame_cls, 101 | filter=None, 102 | per_page=20, 103 | orphans=0, 104 | **filter_args 105 | ): 106 | 107 | # The frame class results are being paginated for 108 | self._frame_cls = frame_cls 109 | 110 | # The filter applied when selecting results from the database (we 111 | # flattern the filter at this point which effectively deep copies. 112 | if isinstance(filter, (Condition, Group)): 113 | self._filter = filter.to_dict() 114 | else: 115 | self._filter = to_refs(filter) 116 | 117 | # Any additional filter arguments applied when selecting results such as 118 | # sort and projection, 119 | self._filter_args = filter_args 120 | 121 | # The number of results that will be displayed per page 122 | self._per_page = per_page 123 | 124 | # If a value is specified for orphans then the last page will be able to 125 | # hold the additional results (up to the value of orphans). This can 126 | # help prevent users being presented with pages contain only a few 127 | # results. 128 | self._orphans = orphans 129 | 130 | # Count the total results being paginated 131 | self._items_count = frame_cls.count(self._filter) 132 | 133 | # Calculated the number of pages 134 | total = self._items_count - orphans 135 | self._page_count = max(1, int(math.ceil(total / float(self._per_page)))) 136 | 137 | # Create a list of page number that can be used to navigate the results 138 | self._page_numbers = range(1, self._page_count + 1) 139 | 140 | def __getitem__(self, page_number): 141 | if page_number not in self._page_numbers: 142 | raise InvalidPage(page_number, self.page_count) 143 | 144 | # Calculate the next and previous page numbers 145 | next = page_number + 1 if page_number + 1 in self._page_numbers else None 146 | prev = page_number - 1 if page_number - 1 in self._page_numbers else None 147 | 148 | # Select the items for the page 149 | filter_args = deepcopy(self._filter_args) or {} 150 | filter_args['skip'] = (page_number - 1) * self._per_page 151 | filter_args['limit'] = self._per_page 152 | 153 | # Check to see if we need to account for orphans 154 | if self.item_count - (page_number * self._per_page) <= self.orphans: 155 | filter_args['limit'] += self.orphans 156 | 157 | # Select the results for the page 158 | items = self._frame_cls.many(self._filter, **filter_args) 159 | 160 | # Build the page 161 | return Page( 162 | offset=filter_args['skip'], 163 | number=page_number, 164 | items=items, 165 | next=next, 166 | prev=prev 167 | ) 168 | 169 | def __iter__(self): 170 | for page_number in self._page_numbers: 171 | yield self[page_number] 172 | 173 | # Read-only properties 174 | 175 | @property 176 | def item_count(self): 177 | """Return the total number of items being paginated""" 178 | return self._items_count 179 | 180 | @property 181 | def orphans(self): 182 | """ 183 | Return the number of orphan results that will be allowed in the last 184 | page of results. 185 | """ 186 | return self._orphans 187 | 188 | @property 189 | def page_count(self): 190 | """Return the total number of pages""" 191 | return self._page_count 192 | 193 | @property 194 | def page_numbers(self): 195 | """Return a list of page numbers""" 196 | return self._page_numbers 197 | 198 | @property 199 | def per_page(self): 200 | """ 201 | Return the number of results per page (with the exception of orphans). 202 | """ 203 | return self._per_page -------------------------------------------------------------------------------- /mongoframes/queries.py: -------------------------------------------------------------------------------- 1 | """ 2 | A set of helpers to simplify the creation of MongoDB queries. 3 | """ 4 | 5 | import re 6 | 7 | from pymongo import (ASCENDING, DESCENDING) 8 | 9 | __all__ = [ 10 | # Queries 11 | 'Q', 12 | 13 | # Operators 14 | 'All', 15 | 'ElemMatch', 16 | 'Exists', 17 | 'In', 18 | 'Not', 19 | 'NotIn', 20 | 'Size', 21 | 'Type', 22 | 23 | # Groups 24 | 'And', 25 | 'Or', 26 | 'Nor', 27 | 28 | # Sorting 29 | 'SortBy', 30 | 31 | # Utils 32 | 'to_refs' 33 | ] 34 | 35 | 36 | # Queries 37 | 38 | class Condition: 39 | """ 40 | A query condition of the form `{path: {operator: value}}`. 41 | """ 42 | 43 | def __init__(self, q, value, operator): 44 | self.q = q 45 | self.value = value 46 | self.operator = operator 47 | 48 | def to_dict(self): 49 | """Return a dictionary suitable for use with pymongo as a filter""" 50 | if self.operator == '$eq': 51 | return {self.q: self.value} 52 | if self.q is None: 53 | return {self.operator: self.value} 54 | return {self.q: {self.operator: self.value}} 55 | 56 | 57 | class QMeta(type): 58 | """ 59 | Meta-class for query builder. 60 | """ 61 | 62 | def __getattr__(self, name): 63 | return Q(name) 64 | 65 | def __getitem__(self, name): 66 | return Q(name) 67 | 68 | def __eq__(self, other): 69 | return Condition(None, other, '$eq') 70 | 71 | def __ge__(self, other): 72 | return Condition(None, other, '$gte') 73 | 74 | def __gt__(self, other): 75 | return Condition(None, other, '$gt') 76 | 77 | def __le__(self, other): 78 | return Condition(None, other, '$lte') 79 | 80 | def __lt__(self, other): 81 | return Condition(None, other, '$lt') 82 | 83 | def __ne__(self, other): 84 | return Condition(None, other, '$ne') 85 | 86 | 87 | class Q(metaclass=QMeta): 88 | """ 89 | Start point for the query creation, the Q class is a special type of class 90 | that's typically initialized by appending an attribute, for example: 91 | 92 | Q.hit_points > 100 93 | 94 | """ 95 | 96 | def __init__(self, path): 97 | self._path = path 98 | 99 | def __eq__(self, other): 100 | return Condition(self._path, other, '$eq') 101 | 102 | def __ge__(self, other): 103 | return Condition(self._path, other, '$gte') 104 | 105 | def __gt__(self, other): 106 | return Condition(self._path, other, '$gt') 107 | 108 | def __le__(self, other): 109 | return Condition(self._path, other, '$lte') 110 | 111 | def __lt__(self, other): 112 | return Condition(self._path, other, '$lt') 113 | 114 | def __ne__(self, other): 115 | return Condition(self._path, other, '$ne') 116 | 117 | def __getattr__(self, name): 118 | self._path = '{0}.{1}'.format(self._path, name) 119 | return self 120 | 121 | def __getitem__(self, name): 122 | self._path = '{0}.{1}'.format(self._path, name) 123 | return self 124 | 125 | 126 | # Operators 127 | 128 | def All(q, value): 129 | """ 130 | The All operator selects documents where the value of the field is an list 131 | that contains all the specified elements. 132 | """ 133 | return Condition(q._path, to_refs(value), '$all') 134 | 135 | def ElemMatch(q, *conditions): 136 | """ 137 | The ElemMatch operator matches documents that contain an array field with at 138 | least one element that matches all the specified query criteria. 139 | """ 140 | new_condition = {} 141 | for condition in conditions: 142 | 143 | if isinstance(condition, (Condition, Group)): 144 | condition = condition.to_dict() 145 | 146 | deep_merge(condition, new_condition) 147 | 148 | return Condition(q._path, new_condition, '$elemMatch') 149 | 150 | def Exists(q, value): 151 | """ 152 | When exists is True, Exists matches the documents that contain the field, 153 | including documents where the field value is null. If exists is False, the 154 | query returns only the documents that do not contain the field. 155 | """ 156 | return Condition(q._path, value, '$exists') 157 | 158 | def In(q, value): 159 | """ 160 | The In operator selects the documents where the value of a field equals any 161 | value in the specified list. 162 | """ 163 | return Condition(q._path, to_refs(value), '$in') 164 | 165 | def Not(condition): 166 | """ 167 | Not performs a logical NOT operation on the specified condition and selects 168 | the documents that do not match. This includes documents that do not contain 169 | the field. 170 | """ 171 | return Condition( 172 | condition.q, 173 | {condition.operator: condition.value}, 174 | '$not' 175 | ) 176 | 177 | def NotIn(q, value): 178 | """ 179 | The NotIn operator selects documents where the field value is not in the 180 | specified list or the field does not exists. 181 | """ 182 | return Condition(q._path, to_refs(value), '$nin') 183 | 184 | def Size(q, value): 185 | """ 186 | The Size operator matches any list with the number of elements specified by 187 | size. 188 | """ 189 | return Condition(q._path, value, '$size') 190 | 191 | def Type(q, value): 192 | """ 193 | Type selects documents where the value of the field is an instance of the 194 | specified BSON type. 195 | """ 196 | return Condition(q._path, value, '$type') 197 | 198 | 199 | # Groups 200 | 201 | class Group: 202 | """ 203 | The Group class is used as a base class for operators that group together 204 | two or more conditions. 205 | """ 206 | 207 | operator = '' 208 | 209 | def __init__(self, *conditions): 210 | self.conditions = conditions 211 | 212 | def to_dict(self): 213 | """Return a dictionary suitable for use with pymongo as a filter""" 214 | raw_conditions = [] 215 | for condition in self.conditions: 216 | if isinstance(condition, (Condition, Group)): 217 | raw_conditions.append(condition.to_dict()) 218 | else: 219 | raw_conditions.append(condition) 220 | return {self.operator: raw_conditions} 221 | 222 | 223 | class And(Group): 224 | """ 225 | And performs a logical AND operation on a list of two or more conditions and 226 | selects the documents that satisfy all the conditions. 227 | """ 228 | 229 | operator = '$and' 230 | 231 | 232 | class Or(Group): 233 | """ 234 | The Or operator performs a logical OR operation on a list of two or more 235 | conditions and selects the documents that satisfy at least one of the 236 | conditions. 237 | """ 238 | 239 | operator = '$or' 240 | 241 | 242 | class Nor(Group): 243 | """ 244 | Nor performs a logical NOR operation on a list of one or more conditions and 245 | selects the documents that fail all the conditions. 246 | """ 247 | 248 | operator = '$nor' 249 | 250 | 251 | # Sorting 252 | 253 | def SortBy(*qs): 254 | """Convert a list of Q objects into list of sort instructions""" 255 | 256 | sort = [] 257 | for q in qs: 258 | if q._path.endswith('.desc'): 259 | sort.append((q._path[:-5], DESCENDING)) 260 | else: 261 | sort.append((q._path, ASCENDING)) 262 | return sort 263 | 264 | 265 | # Utils 266 | 267 | def deep_merge(source, dest): 268 | """ 269 | Deep merges source dict into dest dict. 270 | 271 | This code was taken directly from the mongothon project: 272 | https://github.com/gamechanger/mongothon/tree/master/mongothon 273 | """ 274 | for key, value in source.items(): 275 | if key in dest: 276 | if isinstance(value, dict) and isinstance(dest[key], dict): 277 | deep_merge(value, dest[key]) 278 | continue 279 | elif isinstance(value, list) and isinstance(dest[key], list): 280 | for item in value: 281 | if item not in dest[key]: 282 | dest[key].append(item) 283 | continue 284 | dest[key] = value 285 | 286 | def to_refs(value): 287 | """Convert all Frame instances within the given value to Ids""" 288 | from mongoframes.frames import Frame, SubFrame 289 | 290 | # Frame 291 | if isinstance(value, Frame): 292 | return value._id 293 | 294 | # SubFrame 295 | elif isinstance(value, SubFrame): 296 | return to_refs(value._document) 297 | 298 | # Lists 299 | elif isinstance(value, (list, set, tuple)): 300 | return [to_refs(v) for v in value] 301 | 302 | # Dictionaries 303 | elif isinstance(value, dict): 304 | return {k: to_refs(v) for k, v in value.items()} 305 | 306 | return value 307 | -------------------------------------------------------------------------------- /performance/requirements.txt: -------------------------------------------------------------------------------- 1 | blinker==1.4 2 | fake-factory==0.5.8 3 | mongoengine==0.10.6 4 | MongoFrames==1.0.0 5 | pymongo==3.2.2 6 | python-dateutil==2.5.3 7 | six==1.10.0 -------------------------------------------------------------------------------- /performance/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | 4 | from faker import Faker 5 | from mongoengine import * 6 | from mongoframes import * 7 | from pymongo import MongoClient 8 | 9 | __all__ = [ 10 | 'build_test_data', 11 | 'connect_to_db', 12 | 'time_it' 13 | ] 14 | 15 | 16 | class Company(Frame): 17 | 18 | _fields = { 19 | 'name', 20 | 'departments', 21 | 'address', 22 | 'tel', 23 | 'website_url' 24 | } 25 | 26 | 27 | class Department(SubFrame): 28 | 29 | _fields = { 30 | 'name', 31 | 'year_end', 32 | 'annual_budget' 33 | } 34 | 35 | 36 | class Employee(Frame): 37 | 38 | _fields = { 39 | 'first_name', 40 | 'last_name', 41 | 'dob', 42 | 'role', 43 | 'tel', 44 | 'email', 45 | 'annual_salary', 46 | 'ssn', 47 | 'company', 48 | 'department' 49 | } 50 | 51 | 52 | def build_test_data( 53 | total_companies=100, 54 | total_departments_per_company=10, 55 | total_employees_per_company=50 56 | ): 57 | """Add a context in which we can run the tests with the test data""" 58 | 59 | print('Building test data...') 60 | 61 | # Drop the database collections 62 | Company.get_collection().drop() 63 | Employee.get_collection().drop() 64 | 65 | # Create a faker object 66 | fake = Faker() 67 | fake.seed(11061979) 68 | random.seed(11061979) 69 | 70 | # Build companies 71 | companies = [] 72 | for company_index in range(0, total_companies): 73 | company = Company( 74 | name=fake.company(), 75 | departments=[], 76 | address=fake.address(), 77 | tel=fake.phone_number(), 78 | website_url=fake.uri() 79 | ) 80 | 81 | # Build departments 82 | for department_index in range(0, total_departments_per_company): 83 | department = Department( 84 | name=fake.word(), 85 | year_end=fake.date_time_this_year(False, True), 86 | annual_budget=fake.pyint() 87 | ) 88 | company.departments.append(department) 89 | 90 | companies.append(company) 91 | 92 | Company.insert_many(companies) 93 | 94 | # Build employees 95 | for company in companies: 96 | for employee_index in range(0, total_employees_per_company): 97 | employee = Employee( 98 | first_name=fake.first_name(), 99 | last_name=fake.last_name(), 100 | dob=fake.date(), 101 | role=fake.job(), 102 | tel=fake.phone_number(), 103 | email=fake.email(), 104 | annual_salary=fake.pyint(), 105 | ssn=fake.ssn(), 106 | company=company, 107 | department=random.choice(company.departments) 108 | ) 109 | employee.insert() 110 | 111 | def connect_to_db(): 112 | """Connect to the database""" 113 | 114 | print('Connecting to database...') 115 | 116 | # Connect to database via MongoEngine 117 | connect('mongo_performance_tests') 118 | 119 | # Connect to database via MongoFrames 120 | Frame._client = MongoClient( 121 | 'mongodb://localhost:27017/mongo_performance_tests' 122 | ) 123 | 124 | def time_it(func, calls, *args, **kwargs): 125 | """ 126 | Call the given function X times and output the best, worst and average 127 | execution time. 128 | """ 129 | 130 | # Call the function X times and record the times 131 | times = [] 132 | for call_index in range(calls): 133 | start_time = time.time() 134 | func(*args, **kwargs) 135 | times.append(time.time() - start_time) 136 | 137 | # Get the best, worst and average times 138 | times.sort() 139 | best = times[0] 140 | worst = times[-1] 141 | average = sum(times) / float(len(times)) 142 | 143 | print( 144 | '{func_name: <32} {best: 02.3f} {worst: 02.3f} {average: 02.3f}'.format( 145 | func_name=func.__name__, 146 | best=best, 147 | worst=worst, 148 | average=average 149 | ) 150 | ) -------------------------------------------------------------------------------- /performance/tests/run_mongoengine.py: -------------------------------------------------------------------------------- 1 | from mongoengine import * 2 | 3 | from __init__ import build_test_data, connect_to_db, time_it 4 | 5 | 6 | # Define models 7 | 8 | class Company(Document): 9 | 10 | meta = {'collection': 'Company'} 11 | 12 | name = StringField() 13 | departments = EmbeddedDocumentListField('Department') 14 | address = StringField() 15 | tel = StringField() 16 | website_url = URLField() 17 | 18 | 19 | class Department(EmbeddedDocument): 20 | 21 | meta = {'collection': 'Department'} 22 | 23 | name = StringField() 24 | year_end = DateTimeField() 25 | annual_budget = IntField() 26 | 27 | 28 | class Employee(Document): 29 | 30 | meta = {'collection': 'Employee'} 31 | 32 | first_name = StringField() 33 | last_name = StringField() 34 | dob = StringField() 35 | role = StringField() 36 | tel = StringField() 37 | email = EmailField() 38 | annual_salary = IntField() 39 | ssn = StringField() 40 | company = ReferenceField(Company) 41 | department = StringField() 42 | 43 | 44 | # Define tests 45 | 46 | def test_flat_select(): 47 | """Select all employees no dereferencing""" 48 | list(Employee.objects.no_dereference()) 49 | 50 | def test_embedded_document_select(): 51 | """Select all companies no dereferencing""" 52 | list(Company.objects.no_dereference()) 53 | 54 | def test_full_select(): 55 | """Select all employees and their referenced companies""" 56 | [e.company for e in Employee.objects] 57 | 58 | if __name__ == "__main__": 59 | 60 | # Connect to the database 61 | connect_to_db() 62 | 63 | # Build the test data 64 | build_test_data() 65 | 66 | # Run the tests 67 | #time_it(test_flat_select, 100) 68 | #time_it(test_embedded_document_select, 100) 69 | time_it(test_full_select, 100) -------------------------------------------------------------------------------- /performance/tests/run_mongoframes.py: -------------------------------------------------------------------------------- 1 | from mongoframes import * 2 | 3 | from __init__ import build_test_data, connect_to_db, time_it 4 | 5 | 6 | # Define models 7 | 8 | class Company(Frame): 9 | 10 | _fields = { 11 | 'name', 12 | 'departments', 13 | 'address', 14 | 'tel', 15 | 'website_url' 16 | } 17 | 18 | 19 | class Department(SubFrame): 20 | 21 | _fields = { 22 | 'name', 23 | 'year_end', 24 | 'annual_budget' 25 | } 26 | 27 | 28 | class Employee(Frame): 29 | 30 | _fields = { 31 | 'first_name', 32 | 'last_name', 33 | 'dob', 34 | 'role', 35 | 'tel', 36 | 'email', 37 | 'annual_salary', 38 | 'ssn', 39 | 'company', 40 | 'department' 41 | } 42 | 43 | 44 | # Define tests 45 | 46 | def test_flat_select(): 47 | """Select all employees no dereferencing""" 48 | Employee.many() 49 | 50 | def test_embedded_document_select(): 51 | """Select all companies no dereferencing""" 52 | Company.many(projection={'departments': {'$sub': Department}}) 53 | 54 | def test_full_select(): 55 | """Select all employees and their referenced companies""" 56 | Employee.many(projection={ 57 | 'company': { 58 | '$ref': Company, 59 | 'departments': {'$sub': Department} 60 | } 61 | }) 62 | 63 | 64 | if __name__ == "__main__": 65 | 66 | # Connect to the database 67 | connect_to_db() 68 | 69 | # Build the test data 70 | build_test_data() 71 | 72 | # Run the tests 73 | #time_it(test_flat_select, 100) 74 | #time_it(test_embedded_document_select, 100) 75 | time_it(test_full_select, 100) 76 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup instuctions for mongoframes. 3 | """ 4 | 5 | # Always prefer setuptools over distutils 6 | from setuptools import setup, find_packages 7 | 8 | # To use a consistent encoding 9 | from codecs import open 10 | from os import path 11 | 12 | here = path.abspath(path.dirname(__file__)) 13 | 14 | # Get the long description from the README file 15 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 16 | long_description = f.read() 17 | 18 | setup( 19 | name='MongoFrames', 20 | 21 | # Versions should comply with PEP440. For a discussion on single-sourcing 22 | # the version across setup.py and the project code, see 23 | # https://packaging.python.org/en/latest/single_source_version.html 24 | version='1.3.10', 25 | description='A fast unobtrusive MongoDB ODM for Python', 26 | long_description=long_description, 27 | long_description_content_type='text/markdown', 28 | 29 | # The project's main homepage. 30 | url='https://github.com/GetmeUK/MongoFrames', 31 | 32 | # Author details 33 | author='Anthony Blackshaw', 34 | author_email='ant@getme.co.uk', 35 | 36 | # Maintainer 37 | maintainer="Anthony Blackshaw", 38 | maintainer_email="ant@getme.co.uk", 39 | 40 | # Choose your license 41 | license='MIT', 42 | 43 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 44 | classifiers=[ 45 | # How mature is this project? Common values are 46 | # 3 - Alpha 47 | # 4 - Beta 48 | # 5 - Production/Stable 49 | 'Development Status :: 4 - Beta', 50 | 51 | # Indicate who your project is intended for 52 | 'Intended Audience :: Developers', 53 | 'Topic :: Database', 54 | 55 | # Pick your license as you wish (should match "license" above) 56 | 'License :: OSI Approved :: MIT License', 57 | 58 | # Operating systems 59 | "Operating System :: MacOS :: MacOS X", 60 | "Operating System :: Microsoft :: Windows", 61 | "Operating System :: POSIX", 62 | 63 | # Specify the Python versions you support here. In particular, ensure 64 | # that you indicate whether you support Python 2, Python 3 or both. 65 | 'Programming Language :: Python :: 3.4', 66 | 'Programming Language :: Python :: 3.5' 67 | ], 68 | 69 | # What does your project relate to? 70 | keywords='database mongo mongodb odm pymongo', 71 | 72 | # You can just specify the packages manually here if your project is 73 | # simple. Or you can use find_packages(). 74 | packages=find_packages(exclude=['contrib', 'docs', 'tests']), 75 | 76 | # Alternatively, if you want to distribute just a my_module.py, uncomment 77 | # this: 78 | # py_modules=["my_module"], 79 | 80 | # List run-time dependencies here. These will be installed by pip when 81 | # your project is installed. For an analysis of "install_requires" vs pip's 82 | # requirements files see: 83 | # https://packaging.python.org/en/latest/requirements.html 84 | install_requires=[ 85 | 'blinker>=1.4', 86 | 'Faker>=0.7.18', 87 | 'pymongo>=3.9.0' 88 | ], 89 | 90 | # List additional groups of dependencies here (e.g. development 91 | # dependencies). You can install these using the following syntax, 92 | # for example: 93 | # $ pip install -e .[dev,test] 94 | extras_require={ 95 | 'develop': ['pytest', 'pytest-mock', 'tox'], 96 | 'test': ['pytest', 'pytest-mock', 'tox'] 97 | }, 98 | 99 | # If there are data files included in your packages that need to be 100 | # installed, specify them here. If using Python 2.6 or less, then these 101 | # have to be included in MANIFEST.in as well. 102 | package_data={}, 103 | 104 | # Although 'package_data' is the preferred approach, in some case you may 105 | # need to place data files outside of your packages. See: 106 | # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files # noqa 107 | # In this case, 'data_file' will be installed into '/my_data' 108 | data_files=[], 109 | 110 | # To provide executable scripts, use entry points in preference to the 111 | # "scripts" keyword. Entry points provide cross-platform support and allow 112 | # pip to create the appropriate form of executable for the target platform. 113 | entry_points={} 114 | ) 115 | -------------------------------------------------------------------------------- /snippets/comparable.py: -------------------------------------------------------------------------------- 1 | # comparable.py 2 | 3 | from mongoframes import * 4 | from datetime import date 5 | 6 | __all__ = [ 7 | 'ChangeLogEntry', 8 | 'ComparableFrame' 9 | ] 10 | 11 | 12 | class ChangeLogEntry(Frame): 13 | """ 14 | A class for implementing a change log. Each tracked change to a document is 15 | logged in the `ChangeLogEntry` collection. 16 | """ 17 | 18 | _fields = { 19 | 'created', 20 | 'documents', 21 | 'documents_sticky_label', 22 | 'user', 23 | 'user_sticky_label', 24 | 'type', 25 | 'details' 26 | } 27 | 28 | # A set of HTML templates used to output the *diff* for a change log entry 29 | _templates = { 30 | 'add': ''' 31 |
32 |
{field}
33 |
34 |
35 | {new_value} 36 |
37 |
38 |
39 | ''', 40 | 41 | 'update': ''' 42 |
43 |
{field}
44 |
45 |
46 | {original_value} 47 |
48 |
49 | {new_value} 50 |
51 |
52 |
53 | ''', 54 | 55 | 'delete': ''' 56 |
57 |
{field}
58 |
59 |
60 | {original_value} 61 |
62 |
63 |
64 | ''' 65 | } 66 | 67 | @property 68 | def diff_html(self): 69 | """Return the entries diff in HTML format""" 70 | return self.diff_to_html(self.details) 71 | 72 | @property 73 | def is_diff(self): 74 | """Return True if there are any differences logged""" 75 | if not isinstance(self.details, dict): 76 | return False 77 | 78 | for key in ['additions', 'updates', 'deletions']: 79 | if self.details.get(key, None): 80 | return True 81 | 82 | return False 83 | 84 | def add_diff(self, original, new): 85 | """ 86 | Set the details of the change log entry as the difference between two 87 | dictionaries (original vs. new). The change log uses the following 88 | format: 89 | 90 | { 91 | 'additions': { 92 | 'field_name': 'value', 93 | ... 94 | }, 95 | 'updates': { 96 | 'field_name': ['original_value', 'new_value'], 97 | ... 98 | }, 99 | 'deletions': { 100 | 'field_name': ['original_value'] 101 | } 102 | } 103 | 104 | Values are tested for equality, there is special case handling for 105 | `Frame` class instances (see `diff_safe`) and fields with the word 106 | password in their name are redacted. 107 | 108 | Note: Where possible use diff structures that are flat, performing a 109 | diff on a dictionary which contains sub-dictionaries is not recommended 110 | as the verbose output (see `diff_to_html`) is optimized for flat 111 | structures. 112 | """ 113 | changes = {} 114 | 115 | # Check for additions and updates 116 | for new_key, new_value in new.items(): 117 | 118 | # Additions 119 | if new_key not in original: 120 | if 'additions' not in changes: 121 | changes['additions'] = {} 122 | new_value = self.diff_safe(new_value) 123 | changes['additions'][new_key] = new_value 124 | 125 | # Updates 126 | elif original[new_key] != new_value: 127 | if 'updates' not in changes: 128 | changes['updates'] = {} 129 | 130 | original_value = self.diff_safe(original[new_key]) 131 | new_value = self.diff_safe(new_value) 132 | 133 | changes['updates'][new_key] = [original_value, new_value] 134 | 135 | # Check for password type fields and redact them 136 | if 'password' in new_key: 137 | changes['updates'][new_key] = ['*****', '*****'] 138 | 139 | # Check for deletions 140 | for original_key, original_value in original.items(): 141 | if original_key not in new: 142 | if 'deletions' not in changes: 143 | changes['deletions'] = {} 144 | 145 | original_value = self.diff_safe(original_value) 146 | changes['deletions'][original_key] = original_value 147 | 148 | self.details = changes 149 | 150 | @classmethod 151 | def diff_to_html(cls, details): 152 | """Return an entry's details in HTML format""" 153 | changes = [] 154 | 155 | # Check that there are details to convert to HMTL 156 | if not details: 157 | return '' 158 | 159 | def _frame(value): 160 | """ 161 | Handle converted `Frame` references where the human identifier is 162 | stored against the `_str` key. 163 | """ 164 | if isinstance(value, dict) and '_str' in value: 165 | return value['_str'] 166 | elif isinstance(value, list): 167 | return ', '.join([_frame(v) for v in value]) 168 | return str(value) 169 | 170 | # Additions 171 | fields = sorted(details.get('additions', {})) 172 | for field in fields: 173 | new_value = _frame(details['additions'][field]) 174 | if isinstance(new_value, list): 175 | new_value = ', '.join([_frame(v) for v in new_value]) 176 | 177 | change = cls._templates['add'].format( 178 | field=field, 179 | new_value=new_value 180 | ) 181 | changes.append(change) 182 | 183 | # Updates 184 | fields = sorted(details.get('updates', {})) 185 | for field in fields: 186 | original_value = _frame(details['updates'][field][0]) 187 | if isinstance(original_value, list): 188 | original_value = ', '.join([_frame(v) for v in original_value]) 189 | 190 | new_value = _frame(details['updates'][field][1]) 191 | if isinstance(new_value, list): 192 | new_value = ', '.join([_frame(v) for v in new_value]) 193 | 194 | change = cls._templates['update'].format( 195 | field=field, 196 | original_value=original_value, 197 | new_value=new_value 198 | ) 199 | changes.append(change) 200 | 201 | # Deletions 202 | fields = sorted(details.get('deletions', {})) 203 | for field in fields: 204 | original_value = _frame(details['deletions'][field]) 205 | if isinstance(original_value, list): 206 | original_value = ', '.join([_frame(v) for v in original_value]) 207 | 208 | change = cls._templates['delete'].format( 209 | field=field, 210 | original_value=original_value 211 | ) 212 | changes.append(change) 213 | 214 | return '\n'.join(changes) 215 | 216 | @classmethod 217 | def diff_safe(cls, value): 218 | """Return a value that can be safely stored as a diff""" 219 | if isinstance(value, Frame): 220 | return {'_str': str(value), '_id': value._id} 221 | elif isinstance(value, (list, tuple)): 222 | return [cls.diff_safe(v) for v in value] 223 | return value 224 | 225 | @staticmethod 226 | def _on_insert(sender, frames): 227 | for frame in frames: 228 | 229 | # Record *sticky* labels for the change so even if the documents or 230 | # user are removed from the system their details are retained. 231 | pairs = [(d, d.__class__.__name__) for d in frame.documents] 232 | frame.documents_sticky_label = ', '.join( 233 | ['{0} ({1})'.format(*p) for p in pairs] 234 | ) 235 | 236 | if frame.user: 237 | frame.user_sticky_label = str(frame.user) 238 | 239 | ChangeLogEntry.listen('insert', ChangeLogEntry.timestamp_insert) 240 | ChangeLogEntry.listen('insert', ChangeLogEntry._on_insert) 241 | 242 | 243 | class ComparableFrame(Frame): 244 | """ 245 | A Frame-like base class that provides support for tracking changes to 246 | documents. 247 | 248 | Some important rules for creating comparable frames: 249 | 250 | - Override the `__str__` method of the class to return a human friendly 251 | identity as this method is called when generating a sticky label for the 252 | class. 253 | - Define which fields are references and which `Frame` class they reference 254 | in the `_compared_refs` dictionary if you don't you'll only be able to see 255 | that the ID has changed there will be nothing human identifiable. 256 | """ 257 | 258 | # A set of fields that should be exluded from comparisons/tracking 259 | _uncompared_fields = {'_id'} 260 | 261 | # A map of reference fields and the frames they reference 262 | _compared_refs = {} 263 | 264 | @property 265 | def comparable(self): 266 | """Return a dictionary that can be compared""" 267 | document_dict = self.compare_safe(self._document) 268 | 269 | # Remove uncompared fields 270 | self._remove_keys(document_dict, self._uncompared_fields) 271 | 272 | # Remove any empty values 273 | clean_document_dict = {} 274 | for k, v in document_dict.items(): 275 | if not v and not isinstance(v, (int, float)): 276 | continue 277 | clean_document_dict[k] = v 278 | 279 | # Convert any referenced fields to Frames 280 | for ref_field, ref_cls in self._compared_refs.items(): 281 | ref = getattr(self, ref_field) 282 | if not ref: 283 | continue 284 | 285 | # Check for fields which contain a list of references 286 | if isinstance(ref, list): 287 | if isinstance(ref[0], Frame): 288 | continue 289 | 290 | # Dereference the list of reference IDs 291 | setattr( 292 | clean_document_dict, 293 | ref_field, 294 | ref_cls.many(In(Q._id, ref)) 295 | ) 296 | 297 | else: 298 | if isinstance(ref, Frame): 299 | continue 300 | 301 | # Dereference the reference ID 302 | setattr( 303 | clean_document_dict, 304 | ref_field, 305 | ref_cls.byId(ref) 306 | ) 307 | 308 | return clean_document_dict 309 | 310 | def logged_delete(self, user): 311 | """Delete the document and log the event in the change log""" 312 | 313 | self.delete() 314 | 315 | # Log the change 316 | entry = ChangeLogEntry({ 317 | 'type': 'DELETED', 318 | 'documents': [self], 319 | 'user': user 320 | }) 321 | entry.insert() 322 | 323 | return entry 324 | 325 | def logged_insert(self, user): 326 | """Create and insert the document and log the event in the change log""" 327 | 328 | # Insert the frame's document 329 | self.insert() 330 | 331 | # Log the insert 332 | entry = ChangeLogEntry({ 333 | 'type': 'ADDED', 334 | 'documents': [self], 335 | 'user': user 336 | }) 337 | entry.insert() 338 | 339 | return entry 340 | 341 | def logged_update(self, user, data, *fields): 342 | """ 343 | Update the document with the dictionary of data provided and log the 344 | event in the change log. 345 | """ 346 | 347 | # Get a copy of the frames comparable data before the update 348 | original = self.comparable 349 | 350 | # Update the frame 351 | _fields = fields 352 | if len(fields) == 0: 353 | _fields = data.keys() 354 | 355 | for field in _fields: 356 | if field in data: 357 | setattr(self, field, data[field]) 358 | 359 | self.update(*fields) 360 | 361 | # Create an entry and perform a diff 362 | entry = ChangeLogEntry({ 363 | 'type': 'UPDATED', 364 | 'documents': [self], 365 | 'user': user 366 | }) 367 | entry.add_diff(original, self.comparable) 368 | 369 | # Check there's a change to apply/log 370 | if not entry.is_diff: 371 | return 372 | entry.insert() 373 | 374 | return entry 375 | 376 | @classmethod 377 | def compare_safe(cls, value): 378 | """Return a value that can be safely compared""" 379 | 380 | # Date 381 | if type(value) == date: 382 | return str(value) 383 | 384 | # Lists 385 | elif isinstance(value, (list, tuple)): 386 | return [cls.compare_safe(v) for v in value] 387 | 388 | # Dictionaries 389 | elif isinstance(value, dict): 390 | return {k: cls.compare_safe(v) for k, v in value.items()} 391 | 392 | return value -------------------------------------------------------------------------------- /snippets/frameless.py: -------------------------------------------------------------------------------- 1 | from mongoframes import * 2 | from mongoframes.frames import _FrameMeta 3 | 4 | __all__ = [ 5 | 'Frameless', 6 | 'SubFrameless' 7 | ] 8 | 9 | 10 | class _FramelessMeta(_FrameMeta): 11 | """ 12 | Meta class for `Frameless` to set the `_collection` value if not set. 13 | """ 14 | 15 | def __new__(meta, name, bases, dct): 16 | 17 | # If no collection name is set then use the class name 18 | if dct.get('_collection') is None: 19 | dct['_collection'] = name 20 | 21 | return super(_FramelessMeta, meta).__new__(meta, name, bases, dct) 22 | 23 | 24 | class Frameless(Frame, metaclass=_FramelessMeta): 25 | """ 26 | A Frame-like class with no defined set of fields. 27 | """ 28 | 29 | def __getattr__(self, name): 30 | if '_document' in self.__dict__: 31 | return self.__dict__['_document'].get(name, None) 32 | raise AttributeError( 33 | "'{0}' has no attribute '{1}'".format(self.__class__.__name__, name) 34 | ) 35 | 36 | def __setattr__(self, name, value): 37 | if '_document' in self.__dict__: 38 | self.__dict__['_document'][name] = value 39 | else: 40 | super(Frameless, self).__setattr__(name, value) 41 | 42 | @property 43 | def fields(self): 44 | """Return a list of fields for this document""" 45 | return self._document.keys() 46 | 47 | @classmethod 48 | def _flatten_projection(cls, projection): 49 | """ 50 | Flatten a structured projection (structure projections support for 51 | projections of (to be) dereferenced fields. 52 | """ 53 | 54 | # If `projection` is empty return a full projection 55 | if not projection: 56 | return {'__': False}, {}, {} 57 | 58 | # Flatten the projection 59 | flat_projection = {} 60 | references = {} 61 | subs = {} 62 | inclusive = True 63 | for key, value in deepcopy(projection).items(): 64 | if isinstance(value, dict): 65 | # Store a reference/SubFrame projection 66 | if '$ref' in value: 67 | references[key] = value 68 | elif '$sub' in value or '$sub.' in value: 69 | subs[key] = value 70 | flat_projection[key] = True 71 | 72 | elif key == '$ref': 73 | # Strip any `$ref` key 74 | continue 75 | 76 | elif key == '$sub' or key == '$sub.': 77 | # Strip any `$sub` key 78 | continue 79 | 80 | else: 81 | # Store the root projection value 82 | flat_projection[key] = value 83 | inclusive = False 84 | 85 | # If only references and `SubFrames` were specified in the projection 86 | # then return a full projection. 87 | if inclusive: 88 | flat_projection = {'__': False} 89 | 90 | return flat_projection, references, subs 91 | 92 | 93 | class SubFrameless(SubFrame): 94 | """ 95 | A SubFrame-like class with no defined set of fields. 96 | """ 97 | 98 | def __getattr__(self, name): 99 | if '_document' in self.__dict__: 100 | return self.__dict__['_document'].get(name, None) 101 | raise AttributeError( 102 | "'{0}' has no attribute '{1}'".format(self.__class__.__name__, name) 103 | ) 104 | 105 | def __setattr__(self, name, value): 106 | if '_document' in self.__dict__: 107 | self.__dict__['_document'][name] = value 108 | else: 109 | super(SubFrameless, self).__setattr__(name, value) -------------------------------------------------------------------------------- /snippets/publishing.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from datetime import datetime 3 | from uuid import uuid4 4 | 5 | from flask import g 6 | from mongoframes import * 7 | import pymongo 8 | 9 | __all__ = ['PublisherFrame'] 10 | 11 | 12 | class PublisherFrame(Frame): 13 | """ 14 | The PublisherFrame class supports documents that implement the draft > 15 | published workflow. 16 | """ 17 | 18 | _fields = {'_uid', 'revision'} 19 | _unpublished_fields = {'_id'} 20 | 21 | def __init__(self, *args, **kwargs): 22 | super(PublisherFrame, self).__init__(*args, **kwargs) 23 | 24 | # Ensure a UID is assigned to the document 25 | if not self._uid: 26 | self._uid = str(uuid4()) 27 | 28 | @property 29 | def can_publish(self): 30 | """ 31 | Return True if there is a draft version of the document that's ready to 32 | be published. 33 | """ 34 | with self.published_context(): 35 | published = self.one( 36 | Q._uid == self._uid, 37 | projection={'revision': True} 38 | ) 39 | 40 | if not published: 41 | return True 42 | 43 | with self.draft_context(): 44 | draft = self.one(Q._uid == self._uid, projection={'revision': True}) 45 | 46 | return draft.revision > published.revision 47 | 48 | @property 49 | def can_revert(self): 50 | """ 51 | Return True if we can revert the draft version of the document to the 52 | currently published version. 53 | """ 54 | 55 | if self.can_publish: 56 | with self.published_context(): 57 | return self.count(Q._uid == self._uid) > 0 58 | 59 | return False 60 | 61 | def get_publisher_doc(self): 62 | """Return a publish safe version of the frame's document""" 63 | with self.draft_context(): 64 | # Select the draft document from the database 65 | draft = self.one(Q._uid == self._uid) 66 | publisher_doc = draft._document 67 | 68 | # Remove any keys from the document that should not be transferred 69 | # when publishing. 70 | self._remove_keys(publisher_doc, self._unpublished_fields) 71 | 72 | return publisher_doc 73 | 74 | def publish(self): 75 | """ 76 | Publish the current document. 77 | 78 | NOTE: You must have saved any changes to the draft version of the 79 | document before publishing, unsaved changes wont be published. 80 | """ 81 | publisher_doc = self.get_publisher_doc() 82 | 83 | with self.published_context(): 84 | # Select the published document 85 | published = self.one(Q._uid == self._uid) 86 | 87 | # If there's no published version of the document create one 88 | if not published: 89 | published = self.__class__() 90 | 91 | # Update the document 92 | for field, value in publisher_doc.items(): 93 | setattr(published, field, value) 94 | 95 | # Save published version 96 | published.upsert() 97 | 98 | # Set the revisions number for draft/published version, we use PyMongo 99 | # directly as it's more convienent to use the shared `_uid`. 100 | now = datetime.now() 101 | 102 | with self.draft_context(): 103 | self.get_collection().update( 104 | {'_uid': self._uid}, 105 | {'$set': {'revision': now}} 106 | ) 107 | 108 | with self.published_context(): 109 | self.get_collection().update( 110 | {'_uid': self._uid}, 111 | {'$set': {'revision': now}} 112 | ) 113 | 114 | def new_revision(self, *fields): 115 | """Save a new revision of the document""" 116 | 117 | # Ensure this document is a draft 118 | if not self._id: 119 | assert g.get('draft'), \ 120 | 'Only draft documents can be assigned new revisions' 121 | else: 122 | with self.draft_context(): 123 | assert self.count(Q._id == self._id) == 1, \ 124 | 'Only draft documents can be assigned new revisions' 125 | 126 | # Set the revision 127 | if len(fields) > 0: 128 | fields.append('revision') 129 | 130 | self.revision = datetime.now() 131 | 132 | # Update the document 133 | self.upsert(*fields) 134 | 135 | def delete(self): 136 | """Delete this document and any counterpart document""" 137 | 138 | with self.draft_context(): 139 | draft = self.one(Q._uid == self._uid) 140 | if draft: 141 | super(PublisherFrame, draft).delete() 142 | 143 | with self.published_context(): 144 | published = self.one(Q._uid == self._uid) 145 | if published: 146 | super(PublisherFrame, published).delete() 147 | 148 | def revert(self): 149 | """Revert the document to currently published version""" 150 | 151 | with self.draft_context(): 152 | draft = self.one(Q._uid == self._uid) 153 | 154 | with self.published_context(): 155 | published = self.one(Q._uid == self._uid) 156 | 157 | for field, value in draft._document.items(): 158 | if field in self._unpublished_fields: 159 | continue 160 | setattr(draft, field, getattr(published, field)) 161 | 162 | # Revert the revision 163 | draft.revision = published.revision 164 | 165 | draft.update() 166 | 167 | @classmethod 168 | def get_collection(cls): 169 | """Return a reference to the database collection for the class""" 170 | 171 | # By default the collection returned will be the published collection, 172 | # however if the `draft` flag has been set against the global context 173 | # (e.g `g`) then the collection returned will contain draft documents. 174 | 175 | if g.get('draft'): 176 | return getattr( 177 | cls.get_db(), 178 | '{collection}_draft'.format(collection=cls._collection) 179 | ) 180 | 181 | return getattr(cls.get_db(), cls._collection) 182 | 183 | # Contexts 184 | 185 | @classmethod 186 | @contextmanager 187 | def draft_context(cls): 188 | """Set the context to draft""" 189 | previous_state = g.get('draft') 190 | try: 191 | g.draft = True 192 | yield 193 | finally: 194 | g.draft = previous_state 195 | 196 | @classmethod 197 | @contextmanager 198 | def published_context(cls): 199 | """Set the context to published""" 200 | previous_state = g.get('draft') 201 | try: 202 | g.draft = False 203 | yield 204 | finally: 205 | g.draft = previous_state -------------------------------------------------------------------------------- /snippets/validated.py: -------------------------------------------------------------------------------- 1 | # validated.py 2 | 3 | from mongoframes import * 4 | 5 | __all__ = [ 6 | 'InvalidDocument', 7 | 'ValidatedFrame' 8 | ] 9 | 10 | 11 | class FormData: 12 | """ 13 | A class that wraps a dictionary providing a request like object that can be 14 | used as the `formdata` argument when initializing a `Form`. 15 | """ 16 | 17 | def __init__(self, data): 18 | self._data = {} 19 | for key, value in data.items(): 20 | 21 | if key not in self._data: 22 | self._data[key] = [] 23 | 24 | if isinstance(value, list): 25 | self._data[key] += value 26 | else: 27 | self._data[key].append(value) 28 | 29 | def __iter__(self): 30 | return iter(self._data) 31 | 32 | def __len__(self): 33 | return len(self._data) 34 | 35 | def __contains__(self, name): 36 | return (name in self._data) 37 | 38 | def get(self, key, default=None): 39 | if key in self._data: 40 | return self._data[key][0] 41 | return default 42 | 43 | def getlist(self, key): 44 | return self._data.get(key, []) 45 | 46 | 47 | class InvalidDocument(Exception): 48 | """ 49 | An exception raised when `save` is called and the document fails validation. 50 | """ 51 | 52 | def __init__(self, errors): 53 | super(InvalidDocument, self).__init__(str(errors)) 54 | self.errors = errors 55 | 56 | 57 | class ValidatedFrame(Frame): 58 | 59 | # The form attribute should be assigned a WTForm class 60 | _form = None 61 | 62 | def save(self, *fields): 63 | """Validate the document before inserting/updating it""" 64 | 65 | # If no form is defined then validation is skipped 66 | if not self._form: 67 | return self.upsert(*fields) 68 | 69 | # Build the form data 70 | if not fields: 71 | fields = self._fields 72 | 73 | data = {f: self[f] for f in fields if f in self} 74 | 75 | # Build the form to validate our data with 76 | form = self._form(FormData(data)) 77 | 78 | # Reduce the form fields to match the data being saved 79 | for field in form: 80 | if field.name not in data: 81 | delattr(form, field.name) 82 | 83 | # Validate the document 84 | if not form.validate(): 85 | raise InvalidDocument(form.errors) 86 | 87 | # Document is valid, save the changes :) 88 | self.upsert(*fields)​​​​​​​​​​​ -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetmeUK/MongoFrames/69b9495b2123028b96e0a80d53b8850fe5379b99/tests/__init__.py -------------------------------------------------------------------------------- /tests/factory/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetmeUK/MongoFrames/69b9495b2123028b96e0a80d53b8850fe5379b99/tests/factory/__init__.py -------------------------------------------------------------------------------- /tests/factory/makers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GetmeUK/MongoFrames/69b9495b2123028b96e0a80d53b8850fe5379b99/tests/factory/makers/__init__.py -------------------------------------------------------------------------------- /tests/factory/makers/test_dates.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from mongoframes.factory.makers import dates as date_makers 4 | 5 | from tests.fixtures import * 6 | 7 | 8 | def test_date_between(): 9 | """ 10 | `DateBetween` makers should return a random date between two given dates. 11 | """ 12 | 13 | # Configured between two `datetime.date` instances 14 | min_date = datetime.date(2016, 1, 1) 15 | max_date = datetime.date(2016, 6, 1) 16 | maker = date_makers.DateBetween(min_date, max_date) 17 | 18 | # Calculate the number of days between the two dates 19 | days = (max_date - min_date).total_seconds() / 86400 20 | 21 | # Check the assembled result 22 | assembled = maker._assemble() 23 | assert assembled >= 0 and assembled <= days 24 | 25 | # Check the finished result 26 | finished = maker._finish(assembled) 27 | assert finished == min_date + datetime.timedelta(seconds=assembled * 86400) 28 | 29 | # Configured between two string dates 30 | maker = date_makers.DateBetween('today-5', 'today+5') 31 | 32 | # Check the assembled result 33 | assembled = maker._assemble() 34 | assert assembled >= 0 and assembled <= 10 35 | 36 | # Check the finished result 37 | min_date = datetime.date.today() - datetime.timedelta(seconds=5 * 86400) 38 | finished = maker._finish(assembled) 39 | assert finished == min_date + datetime.timedelta(seconds=assembled * 86400) 40 | 41 | # Check we can correctly parse tomorrow, today, yesterday 42 | maker_cls = date_makers.DateBetween 43 | 44 | # Today 45 | assert maker_cls.parse_date('today') == datetime.date.today() 46 | 47 | # Tomorrow 48 | tomorrow = datetime.date.today() + datetime.timedelta(days=1) 49 | assert maker_cls.parse_date('tomorrow') == tomorrow 50 | 51 | # Yesterday 52 | yesterday = datetime.date.today() - datetime.timedelta(days=1) 53 | assert maker_cls.parse_date('yesterday') == yesterday -------------------------------------------------------------------------------- /tests/factory/makers/test_images.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | 3 | from mongoframes.factory import quotas 4 | from mongoframes.factory.makers import images as image_makers 5 | 6 | from tests.fixtures import * 7 | 8 | 9 | def test_image_url(): 10 | """ 11 | `ImageURL` makers should return the URL for a placeholder image service such 12 | as `http://fakeimg.pl`. 13 | """ 14 | 15 | # Generate an image URL for the default service provider 16 | maker = image_makers.ImageURL( 17 | quotas.Quota(100), 18 | quotas.Quota(200), 19 | background='DDDDDD', 20 | foreground='888888', 21 | options={'text': 'foo'} 22 | ) 23 | 24 | # Check the assembled result 25 | image_url = '//fakeimg.pl/100x200/DDDDDD/888888/?text=foo' 26 | assembled = maker._assemble() 27 | assert assembled == image_url 28 | 29 | # Check the finished result 30 | finished = maker._finish(assembled) 31 | assert finished == image_url 32 | 33 | # Configured for a custom service provider 34 | def placehold_it_formatter( 35 | service_url, 36 | width, 37 | height, 38 | background, 39 | foreground, 40 | options 41 | ): 42 | """Generage an image URL for the placehold.it service""" 43 | 44 | # Build the base URL 45 | image_tmp = '{service_url}/{width}x{height}/{background}/{foreground}' 46 | image_url = image_tmp.format( 47 | service_url=service_url, 48 | width=width, 49 | height=height, 50 | background=background, 51 | foreground=foreground 52 | ) 53 | 54 | # Check for a format option 55 | fmt = '' 56 | if 'format' in options: 57 | fmt = options.pop('format') 58 | image_url += '.' + fmt 59 | 60 | # Add any other options 61 | if options: 62 | image_url += '?' + urlencode(options) 63 | 64 | return image_url 65 | 66 | maker = image_makers.ImageURL( 67 | quotas.Quota(100), 68 | quotas.Quota(200), 69 | background='DDDDDD', 70 | foreground='888888', 71 | options={'text': 'foo', 'format': 'png'}, 72 | service_url='//placehold.it', 73 | service_formatter=placehold_it_formatter 74 | ) 75 | 76 | # Check the assembled result 77 | image_url = '//placehold.it/100x200/DDDDDD/888888.png?text=foo' 78 | assembled = maker._assemble() 79 | assert assembled == image_url 80 | 81 | # Check the finished result 82 | finished = maker._finish(assembled) 83 | assert finished == image_url -------------------------------------------------------------------------------- /tests/factory/makers/test_makers.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from mongoframes.factory import blueprints 4 | from mongoframes.factory import makers 5 | from mongoframes.factory import quotas 6 | from mongoframes.factory.makers import selections as selection_makers 7 | from mongoframes.factory.makers import text as text_makers 8 | 9 | from tests.fixtures import * 10 | 11 | 12 | def test_maker(): 13 | """ 14 | The base maker class should provide context for the current target document. 15 | """ 16 | 17 | document = {'foo': 'bar'} 18 | maker = makers.Maker() 19 | 20 | # Check the target for the maker is correctly set using the `target` context 21 | # method. 22 | with maker.target(document): 23 | assert maker.document == document 24 | 25 | # Once the maker falls out of context check the document has been unset 26 | assert maker.document == None 27 | 28 | def test_dict_of(): 29 | """ 30 | `DictOf` makers should return a dictionary where each key's value is either 31 | a JSON type value the output of a maker. 32 | """ 33 | 34 | maker = makers.DictOf({ 35 | 'json_type': 'foo', 36 | 'maker': makers.Lambda(lambda doc: 'bar') 37 | }) 38 | 39 | # Check the assembled result 40 | assembled = maker._assemble() 41 | assert assembled == { 42 | 'json_type': None, 43 | 'maker': 'bar' 44 | } 45 | 46 | # Check the finished result 47 | finished = maker._finish(assembled) 48 | assert finished == { 49 | 'json_type': 'foo', 50 | 'maker': 'bar' 51 | } 52 | 53 | def test_faker(): 54 | """ 55 | `Faker` makers should call a faker library provider and return the output as 56 | the value. 57 | """ 58 | am_pm = {'AM', 'PM'} 59 | 60 | # Configured as assembler 61 | maker = makers.Faker('am_pm') 62 | 63 | # Check the assembled result 64 | assembled = maker._assemble() 65 | assert assembled in am_pm 66 | 67 | # Check the finished result 68 | finished = maker._finish(assembled) 69 | assert finished in am_pm 70 | 71 | # Configured as finisher 72 | maker = makers.Faker('am_pm', assembler=False) 73 | 74 | # Check the assembled result 75 | assembled = maker._assemble() 76 | assert assembled == None 77 | 78 | # Check the finished result 79 | finished = maker._finish(assembled) 80 | assert finished in am_pm 81 | 82 | # Configured with a different locale 83 | maker = makers.Faker('postcode', locale='en_GB') 84 | 85 | # Check the assembled result resembles a UK postcode 86 | assembled = maker._assemble() 87 | 88 | assert re.match('(\w+?\d{1,2}).*', assembled) and len(assembled) <= 8 89 | 90 | def test_lambda(): 91 | """ 92 | `Lambda` makers should return the output of the function you initialize them 93 | with. 94 | """ 95 | 96 | # Configured as assembler 97 | maker = makers.Lambda(lambda doc: 'foo') 98 | 99 | # Check the assembled result 100 | assembled = maker._assemble() 101 | assert assembled == 'foo' 102 | 103 | # Check the finished result 104 | finished = maker._finish(assembled) 105 | assert finished == 'foo' 106 | 107 | # Configured as finisher 108 | maker = makers.Lambda(lambda doc, v: 'bar', assembler=False, finisher=True) 109 | 110 | # Check the assembled result 111 | assembled = maker._assemble() 112 | assert assembled == None 113 | 114 | # Check the finished result 115 | finished = maker._finish(assembled) 116 | assert finished == 'bar' 117 | 118 | # Configured as both an assembler and finisher 119 | def func(doc, value=None): 120 | if value: 121 | return value + 'bar' 122 | return 'foo' 123 | 124 | maker = makers.Lambda(func, finisher=True) 125 | 126 | # Check the assembled result 127 | assembled = maker._assemble() 128 | assert assembled == 'foo' 129 | 130 | # Check the finished result 131 | finished = maker._finish(assembled) 132 | assert finished == 'foobar' 133 | 134 | def test_list_of(): 135 | """ 136 | `ListOf` makers should return a list of values generated by calling a maker 137 | multiple times. 138 | """ 139 | 140 | # Configured to not reset sub-maker 141 | maker = makers.ListOf( 142 | selection_makers.Cycle(list('abcde')), 143 | quotas.Quota(6) 144 | ) 145 | 146 | # Check the assembled result 147 | assembled = maker._assemble() 148 | assert assembled == [[i, None] for i in [0, 1, 2, 3, 4, 0]] 149 | 150 | # Check the finished result 151 | finished = maker._finish(assembled) 152 | assert finished == list('abcdea') 153 | 154 | # Check that calling the maker again continues from where we left off 155 | assembled = maker._assemble() 156 | assert assembled == [[i, None] for i in [1, 2, 3, 4, 0, 1]] 157 | 158 | # Configured to reset sub-maker 159 | maker = makers.ListOf( 160 | selection_makers.Cycle(list('abcde')), 161 | quotas.Quota(6), 162 | reset_maker=True 163 | ) 164 | 165 | # Call the maker twice 166 | assembled = maker._assemble() 167 | assembled = maker._assemble() 168 | 169 | # Check the result was reset after the first call 170 | assert assembled == [[i, None] for i in [0, 1, 2, 3, 4, 0]] 171 | 172 | def test_static(): 173 | """`Static` makers should return the value you initialize them with""" 174 | 175 | # Configured as assembler 176 | value = {'foo': 'bar'} 177 | maker = makers.Static(value) 178 | 179 | # Check the assembled result 180 | assembled = maker._assemble() 181 | assert assembled == value 182 | 183 | # Check the finished result 184 | finished = maker._finish(assembled) 185 | assert finished == value 186 | 187 | # Configured as finisher 188 | value = {'foo': 'bar'} 189 | maker = makers.Static(value, assembler=False) 190 | 191 | # Check the assembled result 192 | assembled = maker._assemble() 193 | assert assembled == None 194 | 195 | # Check the finished result 196 | finished = maker._finish(assembled) 197 | assert finished == value 198 | 199 | def test_sub_factory(mocker): 200 | """ 201 | `SubFactory` makers should return a sub-frame/document using a blueprint. 202 | """ 203 | 204 | # Define a blueprint 205 | class InventoryBlueprint(blueprints.Blueprint): 206 | 207 | _frame_cls = Inventory 208 | 209 | gold = makers.Static(10) 210 | skulls = makers.Static(100) 211 | 212 | # Configure the maker 213 | maker = makers.SubFactory(InventoryBlueprint) 214 | 215 | # Check the assembled result 216 | assembled = maker._assemble() 217 | assert assembled == {'gold': 10, 'skulls': 100} 218 | 219 | # Check the finished result 220 | finished = maker._finish(assembled) 221 | assert isinstance(finished, Inventory) 222 | assert finished._document == {'gold': 10, 'skulls': 100} 223 | 224 | # Reset should reset the sub factories associated blueprint 225 | mocker.spy(InventoryBlueprint, 'reset') 226 | maker.reset() 227 | assert InventoryBlueprint.reset.call_count == 1 228 | 229 | def test_unique(): 230 | """ 231 | `Unique` makers guarentee a unique value is return from the maker they are 232 | wrapped around. 233 | """ 234 | 235 | # Confifured as assembler 236 | maker = makers.Unique(makers.Faker('name')) 237 | 238 | # Generate 100 random names 239 | names = set([]) 240 | for i in range(0, 20): 241 | assembled = maker._assemble() 242 | assert assembled not in names 243 | names.add(assembled) 244 | 245 | # Confifured as finisher 246 | maker = makers.Unique(makers.Faker('name'), assembler=False) 247 | 248 | # Generate 100 random names 249 | names = set([]) 250 | for i in range(0, 20): 251 | finished = maker._finish(maker._assemble()) 252 | assert finished not in names 253 | names.add(finished) 254 | 255 | # Check that unique will eventually fail if it cannot generate a unique 256 | # response with a maker. 257 | maker = makers.Unique(makers.Static('foo')) 258 | 259 | failed = False 260 | try: 261 | for i in range(0, 100): 262 | finished = maker._finish(maker._assemble()) 263 | except AssertionError: 264 | failed = True 265 | 266 | assert failed 267 | 268 | # Check that we can include a set of initial exluded values 269 | maker = makers.Unique( 270 | text_makers.Sequence('test-{index}'), 271 | exclude={'test-3'} 272 | ) 273 | 274 | names = set([]) 275 | for i in range(0, 9): 276 | assembled = maker._assemble() 277 | names.add(assembled) 278 | 279 | assert 'test-3' not in names 280 | 281 | # Reset should clear the generate unique values from the maker and allow 282 | # those values to be generated again. 283 | maker = makers.Unique(makers.Static('foo'), assembler=False) 284 | 285 | failed = False 286 | try: 287 | for i in range(0, 100): 288 | finished = maker._finish(maker._assemble()) 289 | maker.reset() 290 | 291 | except AssertionError: 292 | failed = True 293 | 294 | assert not failed -------------------------------------------------------------------------------- /tests/factory/makers/test_numbers.py: -------------------------------------------------------------------------------- 1 | from mongoframes.factory import quotas 2 | from mongoframes.factory.makers import numbers as number_makers 3 | 4 | from tests.fixtures import * 5 | 6 | 7 | def test_counter(): 8 | """`Counter` makers should return a number sequence""" 9 | 10 | # Configured with defaults 11 | maker = number_makers.Counter() 12 | 13 | for i in range(1, 100): 14 | # Check the assembled result 15 | assembled = maker._assemble() 16 | assert assembled == i 17 | 18 | # Check the finished result 19 | finished = maker._finish(assembled) 20 | assert finished == i 21 | 22 | # Configuered with custom start from and step 23 | maker = number_makers.Counter(quotas.Quota(10), quotas.Quota(5)) 24 | 25 | for i in range(10, 100, 5): 26 | # Check the assembled result 27 | assembled = maker._assemble() 28 | assert assembled == i 29 | 30 | # Check the finished result 31 | finished = maker._finish(assembled) 32 | assert finished == i 33 | 34 | # Reset should reset the counter to the initial start from value 35 | maker.reset() 36 | 37 | # Check the assembled result 38 | assembled = maker._assemble() 39 | assert assembled == 10 40 | 41 | # Check the finished result 42 | finished = maker._finish(assembled) 43 | assert finished == 10 44 | 45 | def test_float(): 46 | "`Float` makers should return a float between two values" 47 | 48 | min_value = 2.5 49 | max_value = 7.2 50 | maker = number_makers.Float(min_value, max_value) 51 | 52 | for i in range(1, 100): 53 | # Check the assembled result 54 | assembled = maker._assemble() 55 | assert assembled >= min_value and assembled <= max_value 56 | 57 | # Check the finished result 58 | finished = maker._finish(assembled) 59 | assert finished >= min_value and assembled <= max_value 60 | 61 | def test_int(): 62 | "`Int` makers should return an integer between two values" 63 | 64 | min_value = 25 65 | max_value = 72 66 | maker = number_makers.Int(min_value, max_value) 67 | 68 | for i in range(1, 100): 69 | # Check the assembled result 70 | assembled = maker._assemble() 71 | assert assembled >= min_value and assembled <= max_value 72 | 73 | # Check the finished result 74 | finished = maker._finish(assembled) 75 | assert finished >= min_value and assembled <= max_value -------------------------------------------------------------------------------- /tests/factory/makers/test_selections.py: -------------------------------------------------------------------------------- 1 | import math 2 | import random 3 | 4 | from mongoframes.factory import makers 5 | from mongoframes.factory import quotas 6 | from mongoframes.factory.makers import selections as selection_makers 7 | from mongoframes.queries import Q 8 | 9 | from tests.fixtures import * 10 | 11 | 12 | def test_cycle(): 13 | """ 14 | `Cycle` makers should return values from a list of items (python types of or 15 | makers) one after another. 16 | """ 17 | 18 | # Configured to cycle through a list of python types 19 | maker = selection_makers.Cycle(['foo', 'bar', 'zee']) 20 | 21 | index = 0 22 | for value in ['foo', 'bar', 'zee', 'foo', 'bar']: 23 | 24 | # Check the assembled result 25 | assembled = maker._assemble() 26 | assert assembled == [index, None] 27 | 28 | # Check the finished result 29 | finished = maker._finish(assembled) 30 | assert finished == value 31 | 32 | # Track the index 33 | index += 1 34 | if index >= 3: 35 | index = 0 36 | 37 | # Configured to cycle throguh a list of makers 38 | maker = selection_makers.Cycle([ 39 | makers.Static('foo'), 40 | makers.Static('bar'), 41 | makers.Static('zee') 42 | ]) 43 | 44 | index = 0 45 | for value in ['foo', 'bar', 'zee', 'foo', 'bar']: 46 | 47 | # Check the assembled result 48 | assembled = maker._assemble() 49 | assert assembled == [index, value] 50 | 51 | # Check the finished result 52 | finished = maker._finish(assembled) 53 | assert finished == value 54 | 55 | # Track the index 56 | index += 1 57 | if index >= 3: 58 | index = 0 59 | 60 | # Reset should reset the cycle to the start 61 | maker.reset() 62 | 63 | # Check the assembled result 64 | assembled = maker._assemble() 65 | assert assembled == [0, 'foo'] 66 | 67 | # Check the finished result 68 | finished = maker._finish(assembled) 69 | assert finished == 'foo' 70 | 71 | def test_one_of(): 72 | """ 73 | `OneOf` makers should return one value at random (optionally weighted) from 74 | a list of items (python types of or makers). 75 | """ 76 | 77 | # Seed the random generator to ensure test results are consistent 78 | random.seed(110679) 79 | 80 | # Configured to return an item from a list of python types 81 | maker = selection_makers.OneOf(['foo', 'bar', 'zee']) 82 | 83 | counts = {'foo': 0 , 'bar': 0, 'zee': 0} 84 | for i in range(0, 1000): 85 | # Check the assembled result 86 | assembled = maker._assemble() 87 | assert assembled[0] in [0, 1, 2] and assembled[1] == None 88 | 89 | # Check the finished result 90 | finished = maker._finish(assembled) 91 | assert finished in ['foo', 'bar', 'zee'] 92 | 93 | # Count the occurances 94 | counts[finished] += 1 95 | 96 | # Confirm the counts are approx. evenly distributed 97 | for value in ['foo', 'bar', 'zee']: 98 | assert int(round(counts[value] / 100)) == 3 99 | 100 | # Configured to return an item from a list of makers 101 | maker = selection_makers.OneOf([ 102 | makers.Static('foo'), 103 | makers.Static('bar'), 104 | makers.Static('zee') 105 | ]) 106 | 107 | counts = {'foo': 0 , 'bar': 0, 'zee': 0} 108 | for i in range(0, 1000): 109 | # Check the assembled result 110 | assembled = maker._assemble() 111 | assert assembled[0] in [0, 1, 2] 112 | assert assembled[1] in ['foo', 'bar', 'zee'] 113 | 114 | # Check the finished result 115 | finished = maker._finish(assembled) 116 | assert finished in ['foo', 'bar', 'zee'] 117 | 118 | # Count the occurances 119 | counts[finished] += 1 120 | 121 | # Confirm the counts are approx. evenly distributed 122 | for value in ['foo', 'bar', 'zee']: 123 | assert int(round(counts[value] / 100)) == 3 124 | 125 | # Configured to return using a weighted bias 126 | maker = selection_makers.OneOf( 127 | ['foo', 'bar', 'zee'], 128 | [quotas.Quota(10), quotas.Quota(30), quotas.Quota(60)] 129 | ) 130 | 131 | counts = {'foo': 0 , 'bar': 0, 'zee': 0} 132 | for i in range(0, 1000): 133 | # Check the assembled result 134 | assembled = maker._assemble() 135 | assert assembled[0] in [0, 1, 2] and assembled[1] == None 136 | 137 | # Check the finished result 138 | finished = maker._finish(assembled) 139 | assert finished in ['foo', 'bar', 'zee'] 140 | 141 | # Count the occurances 142 | counts[finished] += 1 143 | 144 | # Confirm the counts are approx. distributed based on the weights 145 | assert int(round(counts['foo'] / 100)) == 1 146 | assert int(round(counts['bar'] / 100)) == 3 147 | assert int(round(counts['zee'] / 100)) == 6 148 | 149 | def test_random_reference(mongo_client, example_dataset_many): 150 | """ 151 | `RandomReference` makers should return the Id of a document from from the 152 | specified `Frame`s collection at random. 153 | """ 154 | 155 | # Seed the random generator to ensure test results are consistent 156 | random.seed(110679) 157 | 158 | # Configured without a constraint 159 | maker = selection_makers.RandomReference(ComplexDragon) 160 | 161 | # Check the assembled result 162 | assembled = maker._assemble() 163 | assert math.floor(assembled * 100) == 77 164 | 165 | # Check the finished result 166 | finished = maker._finish(assembled) 167 | assert finished == ComplexDragon.one(Q.name == 'Albert')._id 168 | 169 | # Configured with a constraint 170 | maker = selection_makers.RandomReference(ComplexDragon, Q.name != 'Albert') 171 | 172 | # Check the assembled result 173 | assembled = maker._assemble() 174 | assert math.floor(assembled * 100) == 29 175 | 176 | def test_some_of(): 177 | """ 178 | `SomeOf` makers should return a list of values at random (optionally 179 | weighted) from a list of items (python types of or makers). 180 | """ 181 | 182 | # Seed the random generator to ensure test results are consistent 183 | random.seed(110679) 184 | 185 | # Define the choices we'll be sampling from 186 | choices = ['foo', 'bar', 'zee', 'oof', 'rab', 'eez'] 187 | choices_range = range(0, len(choices)) 188 | choices_set = set(choices) 189 | 190 | # Configured to return a sample from a list of python types 191 | maker = selection_makers.SomeOf(list(choices), quotas.Quota(3)) 192 | 193 | counts = {c: 0 for c in choices} 194 | for i in range(0, 1000): 195 | # Check the assembled result 196 | assembled = maker._assemble() 197 | assert len(assembled) == 3 198 | for item in assembled: 199 | assert item[0] in choices_range and item[1] == None 200 | 201 | # Check the finished result 202 | finished = maker._finish(assembled) 203 | assert len(set(finished)) == 3 204 | assert set(finished).issubset(choices_set) 205 | 206 | # Count occurances 207 | for value in finished: 208 | counts[value] += 1 209 | 210 | # Confirm the counts are approx. evenly distributed 211 | for value in choices: 212 | assert int(round(counts[value] / 100)) == 5 213 | 214 | # Configured to return a sample from a list of makers 215 | maker = selection_makers.SomeOf( 216 | [makers.Static(c) for c in choices], 217 | quotas.Quota(3) 218 | ) 219 | 220 | counts = {c: 0 for c in choices} 221 | for i in range(0, 1000): 222 | # Check the assembled result 223 | assembled = maker._assemble() 224 | assert len(assembled) == 3 225 | for item in assembled: 226 | assert item[0] in choices_range and item[1] in choices 227 | 228 | # Check the finished result 229 | finished = maker._finish(assembled) 230 | assert len(set(finished)) == 3 231 | assert set(finished).issubset(choices_set) 232 | 233 | # Count occurances 234 | for value in finished: 235 | counts[value] += 1 236 | 237 | # Confirm the counts are approx. evenly distributed 238 | for value in choices: 239 | assert int(round(counts[value] / 100)) == 5 240 | 241 | # Configured to return a sample from a list of python types weighted 242 | maker = selection_makers.SomeOf( 243 | list(choices), 244 | quotas.Quota(3), 245 | weights=[1, 2, 4, 8, 16, 32] 246 | ) 247 | 248 | counts = {c: 0 for c in choices} 249 | for i in range(0, 1000): 250 | # Check the assembled result 251 | assembled = maker._assemble() 252 | assert len(assembled) == 3 253 | for item in assembled: 254 | assert item[0] in choices_range and item[1] == None 255 | 256 | # Check the finished result 257 | finished = maker._finish(assembled) 258 | assert len(finished) == 3 259 | assert set(finished).issubset(choices_set) 260 | 261 | # Count occurances 262 | for value in finished: 263 | counts[value] += 1 264 | 265 | # Confirm the counts are approx. based on the weights 266 | for i, value in enumerate(choices): 267 | 268 | count = counts[value] / 1000 269 | prob = maker.p(i, 3, [1, 2, 4, 8, 16, 32]) 270 | tol = prob * 0.15 271 | 272 | assert count > (prob - tol) and count < (prob + tol) 273 | 274 | # Configured to return a sample from a list of python types with replacement 275 | maker = selection_makers.SomeOf( 276 | [makers.Static(c) for c in choices], 277 | quotas.Quota(3), 278 | with_replacement=False 279 | ) 280 | 281 | not_uniques = 0 282 | for i in range(0, 1000): 283 | # Check the assembled result 284 | assembled = maker._assemble() 285 | assert len(assembled) == 3 286 | for item in assembled: 287 | assert item[0] in choices_range and item[1] in choices 288 | 289 | # Check the finished result 290 | finished = maker._finish(assembled) 291 | assert len(finished) == 3 292 | assert set(finished).issubset(choices_set) 293 | 294 | # Count occurances of values with non-unique values 295 | if len(set(value)) < 3: 296 | not_uniques += 1 297 | 298 | # Check that some values where generated with non-unique items 299 | assert not_uniques > 0 300 | 301 | # Configured to return a sample from a list of python types weighted with 302 | # replacement. 303 | maker = selection_makers.SomeOf( 304 | list(choices), 305 | quotas.Quota(3), 306 | weights=[1, 2, 4, 8, 16, 32], 307 | with_replacement=True 308 | ) 309 | 310 | counts = {c: 0 for c in choices} 311 | for i in range(0, 1000): 312 | # Check the assembled result 313 | assembled = maker._assemble() 314 | assert len(assembled) == 3 315 | for item in assembled: 316 | assert item[0] in choices_range and item[1] == None 317 | 318 | # Check the finished result 319 | finished = maker._finish(assembled) 320 | assert len(finished) == 3 321 | assert set(finished).issubset(choices_set) 322 | 323 | # Count occurances 324 | for value in finished: 325 | counts[value] += 1 326 | 327 | # Confirm the counts are approx. (+/- 15% tolerance) based on the weights 328 | weight = 1 329 | for value in choices: 330 | count = counts[value] 331 | prob = (weight / 63.0) * 3000.0 332 | tol = prob * 0.15 333 | 334 | assert count > (prob - tol) and count < (prob + tol) 335 | 336 | weight *= 2 337 | -------------------------------------------------------------------------------- /tests/factory/makers/test_text.py: -------------------------------------------------------------------------------- 1 | from mongoframes.factory import makers 2 | from mongoframes.factory import quotas 3 | from mongoframes.factory.makers import text as text_makers 4 | 5 | 6 | def test_code(): 7 | """`Code` makers should return a random code""" 8 | 9 | # Configured with the default character set 10 | maker = text_makers.Code(quotas.Quota(4)) 11 | 12 | # Check the assembled result 13 | assembled = maker._assemble() 14 | assert len(assembled) == 4 15 | assert set(assembled).issubset(set(maker.default_charset)) 16 | 17 | # Check the finished result 18 | finished = maker._finish(assembled) 19 | assert len(finished) == 4 20 | assert set(assembled).issubset(set(maker.default_charset)) 21 | 22 | # Configured with a custom charset 23 | custom_charset = 'ABCDEF1234567890' 24 | maker = text_makers.Code(quotas.Quota(6), custom_charset) 25 | 26 | # Check the assembled result 27 | assembled = maker._assemble() 28 | assert len(assembled) == 6 29 | assert set(assembled).issubset(set(custom_charset)) 30 | 31 | # Check the finished result 32 | finished = maker._finish(assembled) 33 | assert len(finished) == 6 34 | assert set(assembled).issubset(set(custom_charset)) 35 | 36 | def test_join(): 37 | """ 38 | `Join` makers should return a the value of one or more items (python strings 39 | or makers) joined together. 40 | """ 41 | 42 | # Configured with a list of python strings 43 | maker = text_makers.Join(['foo', 'bar', 'zee']) 44 | 45 | # Check the assembled result 46 | assembled = maker._assemble() 47 | assert assembled == ['foo', 'bar', 'zee'] 48 | 49 | # Check the finished result 50 | finished = maker._finish(assembled) 51 | assert finished == 'foo bar zee' 52 | 53 | # Configured with a list of makers 54 | maker = text_makers.Join([ 55 | makers.Static('foo'), 56 | makers.Static('bar'), 57 | makers.Static('zee') 58 | ]) 59 | 60 | # Check the assembled result 61 | assembled = maker._assemble() 62 | assert assembled == ['foo', 'bar', 'zee'] 63 | 64 | # Check the finished result 65 | finished = maker._finish(assembled) 66 | assert finished == 'foo bar zee' 67 | 68 | # Configured with a custom separator 69 | maker = text_makers.Join(['foo', 'bar', 'zee'], '-') 70 | 71 | # Check the assembled result 72 | assembled = maker._assemble() 73 | assert assembled == ['foo', 'bar', 'zee'] 74 | 75 | # Check the finished result 76 | finished = maker._finish(assembled) 77 | assert finished == 'foo-bar-zee' 78 | 79 | def test_lorem(): 80 | """`Lorem` makers should return lorem ipsum""" 81 | 82 | # Configured to return a body of text 83 | maker = text_makers.Lorem('body', quotas.Quota(5)) 84 | 85 | # Check the assembled result 86 | assembled = maker._assemble() 87 | assert len(assembled.split('\n')) == 5 88 | 89 | # Check the finished result 90 | finished = maker._finish(assembled) 91 | assert len(finished.split('\n')) == 5 92 | 93 | # Configured to return a paragraph 94 | maker = text_makers.Lorem('paragraph', quotas.Quota(5)) 95 | 96 | # Check the assembled result 97 | assembled = maker._assemble() 98 | paragraphs = [p for p in assembled.split('.') if p.strip()] 99 | assert len(paragraphs) == 5 100 | 101 | # Check the finished result 102 | finished = maker._finish(assembled) 103 | paragraphs = [p for p in finished.split('.') if p.strip()] 104 | assert len(paragraphs) == 5 105 | 106 | # Configured to return a sentence 107 | maker = text_makers.Lorem('sentence', quotas.Quota(5)) 108 | 109 | # Check the assembled result 110 | assembled = maker._assemble() 111 | words = [w for w in assembled.split(' ') if w.strip()] 112 | assert len(words) == 5 113 | 114 | # Check the finished result 115 | finished = maker._finish(assembled) 116 | words = [w for w in finished.split(' ') if w.strip()] 117 | assert len(words) == 5 118 | 119 | def test_markov(): 120 | """`Markov` makers should return random text built from a text body""" 121 | 122 | # Set up markov word database 123 | with open('tests/factory/data/markov.txt') as f: 124 | text_makers.Markov.init_word_db('test', f.read()) 125 | 126 | # Configured to return a body of text 127 | maker = text_makers.Markov('test', 'body', quotas.Quota(5)) 128 | 129 | # Check the assembled result 130 | assembled = maker._assemble() 131 | assert len(assembled.split('\n')) == 5 132 | 133 | # Check the finished result 134 | finished = maker._finish(assembled) 135 | assert len(finished.split('\n')) == 5 136 | 137 | # Configured to return a paragraph 138 | maker = text_makers.Markov('test', 'paragraph', quotas.Quota(5)) 139 | 140 | # Check the assembled result 141 | assembled = maker._assemble() 142 | paragraphs = [p for p in assembled.split('.') if p.strip()] 143 | assert len(paragraphs) == 5 144 | 145 | # Check the finished result 146 | finished = maker._finish(assembled) 147 | paragraphs = [p for p in finished.split('.') if p.strip()] 148 | assert len(paragraphs) == 5 149 | 150 | # Configured to return a sentence 151 | maker = text_makers.Markov('test', 'sentence', quotas.Quota(5)) 152 | 153 | # Check the assembled result 154 | assembled = maker._assemble() 155 | words = [w for w in assembled.split(' ') if w.strip()] 156 | assert len(words) == 5 157 | 158 | # Check the finished result 159 | finished = maker._finish(assembled) 160 | words = [w for w in finished.split(' ') if w.strip()] 161 | assert len(words) == 5 162 | 163 | def test_sequence(): 164 | """ 165 | `Sequence` makers should generate a sequence of strings containing a 166 | sequence of numbers. 167 | """ 168 | 169 | # Configured with the defaut start from 170 | maker = text_makers.Sequence('foo-{index}') 171 | 172 | for i in range(0, 10): 173 | 174 | # Check the assembled result 175 | assembled = maker._assemble() 176 | assert assembled == 'foo-{index}'.format(index=i + 1) 177 | 178 | # Check the finished result 179 | finished = maker._finish(assembled) 180 | assert finished == 'foo-{index}'.format(index=i + 1) 181 | 182 | # Configured with a custom start from 183 | maker = text_makers.Sequence('foo-{index}', 5) 184 | 185 | for i in range(0, 10): 186 | 187 | # Check the assembled result 188 | assembled = maker._assemble() 189 | assert assembled == 'foo-{index}'.format(index=i + 5) 190 | 191 | # Check the finished result 192 | finished = maker._finish(assembled) 193 | assert finished == 'foo-{index}'.format(index=i + 5) 194 | 195 | # Reset should reset the sequence to start from 196 | maker.reset() 197 | 198 | # Check the assembled result 199 | assembled = maker._assemble() 200 | assert assembled == 'foo-5' 201 | 202 | # Check the finished result 203 | finished = maker._finish(assembled) 204 | assert finished == 'foo-5' -------------------------------------------------------------------------------- /tests/factory/test_blueprints.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import re 3 | 4 | from mongoframes.factory import blueprints 5 | from mongoframes.factory import makers 6 | 7 | from tests.fixtures import * 8 | 9 | 10 | def test_blueprint_defaults(): 11 | """ 12 | Defining a `Blueprint` class should provide some defaults. 13 | """ 14 | 15 | class DragonBlueprint(blueprints.Blueprint): 16 | 17 | _frame_cls = Dragon 18 | 19 | assert DragonBlueprint._frame_cls == Dragon 20 | assert DragonBlueprint._instructions == {} 21 | assert DragonBlueprint._meta_fields == set([]) 22 | 23 | def test_blueprint_getters(): 24 | """ 25 | The `Blueprint` getter methods: 26 | 27 | - `get_frame_cls` 28 | - `get_instructions` 29 | - `get_meta_fields` 30 | 31 | Should return correct values. 32 | """ 33 | 34 | # Configure the blueprint 35 | class DragonBlueprint(blueprints.Blueprint): 36 | 37 | _frame_cls = Dragon 38 | _meta_fields = {'dummy_prop'} 39 | 40 | name = makers.Static('Burt') 41 | breed = makers.Static('Fire-drake') 42 | dummy_prop = makers.Static('foo') 43 | 44 | # Check the getter return values 45 | assert DragonBlueprint.get_frame_cls() == Dragon 46 | assert DragonBlueprint.get_instructions() == OrderedDict([ 47 | ('name', DragonBlueprint.name), 48 | ('breed', DragonBlueprint.breed), 49 | ('dummy_prop', DragonBlueprint.dummy_prop) 50 | ]) 51 | assert DragonBlueprint.get_meta_fields() == {'dummy_prop'} 52 | 53 | def test_blueprint_assemble(): 54 | """ 55 | The `Blueprint.assemble` method should return an assembled a 56 | document/dictionary for the blueprint. 57 | """ 58 | 59 | # Configure the blueprint 60 | class DragonBlueprint(blueprints.Blueprint): 61 | 62 | _frame_cls = Dragon 63 | _meta_fields = {'dummy_prop'} 64 | 65 | name = makers.Static('Burt') 66 | breed = makers.Static('Fire-drake') 67 | dummy_prop = makers.Static('foo') 68 | 69 | # Check the assembled output of the blueprint is as expected 70 | assembled = DragonBlueprint.assemble() 71 | 72 | assert assembled == { 73 | 'breed': 'Fire-drake', 74 | 'dummy_prop': 'foo', 75 | 'name': 'Burt' 76 | } 77 | 78 | def test_blueprint_finish(): 79 | """ 80 | The `Blueprint.finish` method should return a finished document/dictionary 81 | and meta document/dictionary for the blueprint. 82 | """ 83 | 84 | # Configure the blueprint 85 | class DragonBlueprint(blueprints.Blueprint): 86 | 87 | _frame_cls = Dragon 88 | _meta_fields = {'dummy_prop'} 89 | 90 | name = makers.Static('Burt') 91 | breed = makers.Static('Fire-drake') 92 | dummy_prop = makers.Static('foo') 93 | 94 | # Check the finished output of the blueprint is as expected 95 | finished = DragonBlueprint.finish(DragonBlueprint.assemble()) 96 | 97 | assert finished == { 98 | 'breed': 'Fire-drake', 99 | 'dummy_prop': 'foo', 100 | 'name': 'Burt' 101 | } 102 | 103 | def test_blueprint_reassemble(): 104 | """ 105 | The `Blueprint.reassemble` method should reassemble the given fields in a 106 | preassembled document/dictionary for the blueprint. 107 | """ 108 | 109 | # Configure the blueprint 110 | class DragonBlueprint(blueprints.Blueprint): 111 | 112 | _frame_cls = Dragon 113 | _meta_fields = {'dummy_prop'} 114 | 115 | name = makers.Static('Burt') 116 | breed = makers.Static('Fire-drake') 117 | dummy_prop = makers.Static('foo') 118 | 119 | # Check the assembled output of the blueprint is as expected 120 | assembled = DragonBlueprint.assemble() 121 | 122 | # Re-configure the blueprint 123 | class DragonBlueprint(blueprints.Blueprint): 124 | 125 | _frame_cls = Dragon 126 | _meta_fields = {'dummy_prop'} 127 | 128 | name = makers.Static('Fred') 129 | breed = makers.Static('Cold-drake') 130 | dummy_prop = makers.Static('bar') 131 | 132 | # Check the reassembled output for the blueprint is as expected 133 | DragonBlueprint.reassemble({'breed', 'name'}, assembled) 134 | 135 | assert assembled == { 136 | 'breed': 'Cold-drake', 137 | 'dummy_prop': 'foo', 138 | 'name': 'Fred' 139 | } 140 | 141 | def test_blueprint_reset(mocker): 142 | """ 143 | The `Blueprint.reset` method should call the reset of all makers in the 144 | blueprints instructions. 145 | """ 146 | 147 | # Configure the blueprint 148 | class DragonBlueprint(blueprints.Blueprint): 149 | 150 | _frame_cls = Dragon 151 | _meta_fields = {'dummy_prop'} 152 | 153 | name = makers.Static('Burt') 154 | breed = makers.Static('Fire-drake') 155 | dummy_prop = makers.Static('foo') 156 | 157 | # Spy on the maker reset methods 158 | mocker.spy(DragonBlueprint._instructions['name'], 'reset') 159 | mocker.spy(DragonBlueprint._instructions['breed'], 'reset') 160 | mocker.spy(DragonBlueprint._instructions['dummy_prop'], 'reset') 161 | 162 | # Reset the blueprint 163 | DragonBlueprint.reset() 164 | 165 | # Check each maker reset method was called 166 | assert DragonBlueprint._instructions['name'].reset.call_count == 1 167 | assert DragonBlueprint._instructions['breed'].reset.call_count == 1 168 | assert DragonBlueprint._instructions['dummy_prop'].reset.call_count == 1 -------------------------------------------------------------------------------- /tests/factory/test_factory.py: -------------------------------------------------------------------------------- 1 | from mongoframes.factory import Factory 2 | from mongoframes.factory import blueprints 3 | from mongoframes.factory import makers 4 | from mongoframes.factory import quotas 5 | 6 | from tests.fixtures import * 7 | 8 | 9 | def test_factory_assemble(): 10 | """ 11 | The `Factory.assemble` method should return a quota of assembled 12 | documents/dictionaries for the given blueprint. 13 | """ 14 | 15 | # Configure the blueprint 16 | class DragonBlueprint(blueprints.Blueprint): 17 | 18 | _frame_cls = Dragon 19 | _meta_fields = {'dummy_prop'} 20 | 21 | name = makers.Static('Burt') 22 | breed = makers.Static('Fire-drake') 23 | dummy_prop = makers.Static('foo') 24 | 25 | # Configure the factory 26 | factory = Factory() 27 | 28 | # Assemble a list of documents using the factory 29 | documents = factory.assemble(DragonBlueprint, quotas.Quota(10)) 30 | 31 | # Check the assembled output of the factory is as expected 32 | for document in documents: 33 | assert document == { 34 | 'breed': 'Fire-drake', 35 | 'dummy_prop': 'foo', 36 | 'name': 'Burt' 37 | } 38 | 39 | def test_factory_finish(): 40 | """ 41 | The `Factory.assemble` method should return a list of finished 42 | documents/dictionaries and meta documents/dictionaries in the form 43 | `[(document, meta_document), ...]` from a blueprint and list of preassembled 44 | documents/dictionaries. 45 | """ 46 | 47 | # Configure the blueprint 48 | class DragonBlueprint(blueprints.Blueprint): 49 | 50 | _frame_cls = Dragon 51 | _meta_fields = {'dummy_prop'} 52 | 53 | name = makers.Static('Burt') 54 | breed = makers.Static('Fire-drake') 55 | dummy_prop = makers.Static('foo') 56 | 57 | # Configure the factory 58 | factory = Factory() 59 | 60 | # Assemble a list of documents using the factory 61 | documents = factory.assemble(DragonBlueprint, quotas.Quota(10)) 62 | 63 | # Finish the assembled documents 64 | documents = factory.finish(DragonBlueprint, documents) 65 | 66 | # Check the assembled output of the factory is as expected 67 | for document in documents: 68 | assert document == { 69 | 'breed': 'Fire-drake', 70 | 'name': 'Burt', 71 | 'dummy_prop': 'foo' 72 | } 73 | 74 | def test_factory_populate(mongo_client, mocker): 75 | """ 76 | The `Factory.populate` method should return populate a database collection 77 | using using a blueprint and list of preassembled documents/dictionaries. 78 | """ 79 | 80 | # Configure the blueprint 81 | class DragonBlueprint(blueprints.Blueprint): 82 | 83 | _frame_cls = Dragon 84 | _meta_fields = {'dummy_prop'} 85 | 86 | name = makers.Static('Burt') 87 | breed = makers.Static('Fire-drake') 88 | dummy_prop = makers.Static('foo') 89 | 90 | # Configure the factory 91 | factory = Factory() 92 | 93 | # Assemble a list of documents using the factory 94 | documents = factory.assemble(DragonBlueprint, quotas.Quota(10)) 95 | 96 | # Add listeners for fake and faked 97 | Dragon._on_fake = lambda sender, frames: print('qwe') 98 | Dragon._on_faked = lambda sender, frames: None 99 | 100 | mocker.spy(Dragon, '_on_fake') 101 | mocker.spy(Dragon, '_on_faked') 102 | 103 | Dragon.listen('fake', Dragon._on_fake) 104 | Dragon.listen('faked', Dragon._on_faked) 105 | 106 | # Populate the database with our fake documents 107 | frames = factory.populate(DragonBlueprint, documents) 108 | 109 | # Check each maker reset method was called 110 | assert Dragon._on_fake.call_count == 1 111 | assert Dragon._on_faked.call_count == 1 112 | 113 | # Check the frames created 114 | for frame in frames: 115 | assert frame._id is not None 116 | assert frame.name == 'Burt' 117 | assert frame.breed == 'Fire-drake' 118 | assert frame.dummy_prop 119 | 120 | 121 | def test_factory_reassemble(): 122 | """ 123 | The `Blueprint.reassemble` method should reassemble the given fields in a 124 | list of preassembled documents/dictionaries. 125 | """ 126 | 127 | # Configure the blueprint 128 | class DragonBlueprint(blueprints.Blueprint): 129 | 130 | _frame_cls = Dragon 131 | _meta_fields = {'dummy_prop'} 132 | 133 | name = makers.Static('Burt') 134 | breed = makers.Static('Fire-drake') 135 | dummy_prop = makers.Static('foo') 136 | 137 | # Configure the factory 138 | factory = Factory() 139 | 140 | # Assemble a list of documents using the factory 141 | documents = factory.assemble(DragonBlueprint, quotas.Quota(10)) 142 | 143 | # Re-configure the blueprint and factory 144 | class DragonBlueprint(blueprints.Blueprint): 145 | 146 | _frame_cls = Dragon 147 | _meta_fields = {'dummy_prop'} 148 | 149 | name = makers.Static('Fred') 150 | breed = makers.Static('Cold-drake') 151 | dummy_prop = makers.Static('bar') 152 | 153 | factory = Factory() 154 | 155 | # Reassemble the documents 156 | factory.reassemble(DragonBlueprint, {'breed', 'name'}, documents) 157 | 158 | # Check the reassembled output of the factory is as expected 159 | for document in documents: 160 | assert document == { 161 | 'breed': 'Cold-drake', 162 | 'dummy_prop': 'foo', 163 | 'name': 'Fred' 164 | } -------------------------------------------------------------------------------- /tests/factory/test_quotas.py: -------------------------------------------------------------------------------- 1 | import math 2 | import random 3 | 4 | from mongoframes.factory import quotas 5 | 6 | 7 | def test_quota(): 8 | """ 9 | The base `Quota` should convert to a quantity identical to the one it is 10 | initialized with. 11 | """ 12 | 13 | quota = quotas.Quota(5) 14 | 15 | # Check quota converted to an integer 16 | assert int(quota) == 5 17 | 18 | # Check quota converted to a float 19 | assert float(quota) == 5.0 20 | 21 | def test_gauss(): 22 | """ 23 | The `Gauss` quota should convert to a random quantity using a gaussian 24 | distribution and a given mean and standard deviation. 25 | """ 26 | 27 | # Seed the random generator to ensure test results are consistent 28 | random.seed(110679) 29 | 30 | # Configure the quota 31 | std_dev = 0.1 32 | mean = 5 33 | quota = quotas.Gauss(mean, std_dev) 34 | 35 | # Count the number of times a value falls within each deviation 36 | std_devs = {1: 0, 2: 0, 3: 0} 37 | 38 | for i in range(0, 10000): 39 | qty = float(quota) 40 | 41 | # Calculate the deviation of the quantity from the mean 42 | dev = abs(qty - 5) 43 | 44 | # Tally the occurances within each deviation deviations 45 | if dev <= std_dev: 46 | std_devs[1] += 1 47 | 48 | if dev <= std_dev * 2: 49 | std_devs[2] += 1 50 | 51 | if dev <= std_dev * 3: 52 | std_devs[3] += 1 53 | 54 | # Check the deviations fall within the 68-95-99.7 rule 55 | assert math.ceil(std_devs[1] / 100) >= 68 56 | assert math.ceil(std_devs[2] / 100) >= 95 57 | assert math.ceil(std_devs[3] / 100) >= 99.7 58 | 59 | def test_random(): 60 | """ 61 | The `Random` quota should convert to a random quantity between a minimum and 62 | maximum value. 63 | """ 64 | 65 | min_qty = 5 66 | max_qty = 25 67 | quota = quotas.Random(min_qty, max_qty) 68 | 69 | for i in range(0, 1000): 70 | qty_int = int(quota) 71 | qty_float = float(quota) 72 | 73 | # Check the quantity falls between the given min/max range 74 | assert qty_int >= min_qty and qty_int <= max_qty 75 | assert qty_float >= min_qty and qty_float <= max_qty -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pymongo import MongoClient 4 | import pytest 5 | 6 | from mongoframes import * 7 | 8 | __all__ = [ 9 | # Frames 10 | 'Dragon', 11 | 'Inventory', 12 | 'Lair', 13 | 'ComplexDragon', 14 | 'MonitoredDragon', 15 | 16 | # Fixtures 17 | 'mongo_client', 18 | 'example_dataset_one', 19 | 'example_dataset_many' 20 | ] 21 | 22 | 23 | # Classes 24 | 25 | class Dragon(Frame): 26 | """ 27 | A dragon. 28 | """ 29 | 30 | _fields = { 31 | 'name', 32 | 'breed' 33 | } 34 | _private_fields = {'breed'} 35 | 36 | def _get_dummy_prop(self): 37 | return self._dummy_prop 38 | 39 | def _set_dummy_prop(self, value): 40 | self._dummy_prop = True 41 | 42 | dummy_prop = property(_get_dummy_prop, _set_dummy_prop) 43 | 44 | 45 | class Inventory(SubFrame): 46 | """ 47 | An inventory of items kept within a lair. 48 | """ 49 | 50 | _fields = { 51 | 'gold', 52 | 'skulls' 53 | } 54 | _private_fields = {'gold'} 55 | 56 | 57 | class Lair(Frame): 58 | """ 59 | A lair in which a dragon resides. 60 | """ 61 | 62 | _fields = { 63 | 'name', 64 | 'inventory' 65 | } 66 | 67 | 68 | class ComplexDragon(Dragon): 69 | 70 | _fields = Dragon._fields | { 71 | 'dob', 72 | 'lair', 73 | 'traits', 74 | 'misc' 75 | } 76 | 77 | _default_projection = { 78 | 'lair': { 79 | '$ref': Lair, 80 | 'inventory': {'$sub': Inventory} 81 | } 82 | } 83 | 84 | class MonitoredDragon(Dragon): 85 | 86 | _fields = Dragon._fields | { 87 | 'created', 88 | 'modified' 89 | } 90 | 91 | 92 | # Fixtures 93 | 94 | @pytest.fixture(scope='function') 95 | def mongo_client(request): 96 | """Connect to the test database""" 97 | 98 | # Connect to mongodb and create a test database 99 | Frame._client = MongoClient('mongodb://localhost:27017/mongoframes_test') 100 | 101 | def fin(): 102 | # Remove the test database 103 | Frame._client.drop_database('mongoframes_test') 104 | 105 | request.addfinalizer(fin) 106 | 107 | return Frame._client 108 | 109 | @pytest.fixture(scope='function') 110 | def example_dataset_one(request): 111 | """Create an example set of data that can be used in testing""" 112 | inventory = Inventory( 113 | gold=1000, 114 | skulls=100 115 | ) 116 | 117 | cave = Lair( 118 | name='Cave', 119 | inventory=inventory 120 | ) 121 | cave.insert() 122 | 123 | burt = ComplexDragon( 124 | name='Burt', 125 | dob=datetime(1979, 6, 11), 126 | breed='Cold-drake', 127 | lair=cave, 128 | traits=['irritable', 'narcissistic'] 129 | ) 130 | burt.insert() 131 | 132 | @pytest.fixture(scope='function') 133 | def example_dataset_many(request): 134 | """Create an example set of data that can be used in testing""" 135 | 136 | # Burt 137 | cave = Lair( 138 | name='Cave', 139 | inventory=Inventory( 140 | gold=1000, 141 | skulls=100 142 | ) 143 | ) 144 | cave.insert() 145 | 146 | burt = ComplexDragon( 147 | name='Burt', 148 | dob=datetime(1979, 6, 11), 149 | breed='Cold-drake', 150 | lair=cave, 151 | traits=['irritable', 'narcissistic'] 152 | ) 153 | burt.insert() 154 | 155 | # Fred 156 | castle = Lair( 157 | name='Castle', 158 | inventory=Inventory( 159 | gold=2000, 160 | skulls=200 161 | ) 162 | ) 163 | castle.insert() 164 | 165 | fred = ComplexDragon( 166 | name='Fred', 167 | dob=datetime(1980, 7, 12), 168 | breed='Fire-drake', 169 | lair=castle, 170 | traits=['impulsive', 'loyal'] 171 | ) 172 | fred.insert() 173 | 174 | # Fred 175 | mountain = Lair( 176 | name='Mountain', 177 | inventory=Inventory( 178 | gold=3000, 179 | skulls=300 180 | ) 181 | ) 182 | mountain.insert() 183 | 184 | albert = ComplexDragon( 185 | name='Albert', 186 | dob=datetime(1981, 8, 13), 187 | breed='Stone dragon', 188 | lair=mountain, 189 | traits=['reclusive', 'cunning'] 190 | ) 191 | albert.insert() -------------------------------------------------------------------------------- /tests/test_frames.py: -------------------------------------------------------------------------------- 1 | from bson.objectid import ObjectId 2 | from datetime import datetime, timedelta, timezone 3 | from time import sleep 4 | from unittest.mock import Mock 5 | 6 | from pymongo import ReadPreference 7 | from mongoframes import * 8 | 9 | from tests.fixtures import * 10 | 11 | 12 | # Tests 13 | 14 | def test_frame(): 15 | """Should create a new Dragon instance""" 16 | 17 | # Passing no inital values 18 | burt = Dragon() 19 | assert isinstance(burt, Dragon) 20 | 21 | # Passing initial values 22 | burt = Dragon( 23 | name='Burt', 24 | breed='Cold-drake' 25 | ) 26 | assert burt.name == 'Burt' 27 | assert burt.breed == 'Cold-drake' 28 | 29 | def test_dot_notation(): 30 | """ 31 | Should allow access to read and set document values using do notation. 32 | """ 33 | 34 | # Simple set/get 35 | burt = Dragon( 36 | name='Burt', 37 | breed='Cold-drake' 38 | ) 39 | 40 | assert burt.name == 'Burt' 41 | burt.name = 'Fred' 42 | assert burt.name == 'Fred' 43 | 44 | # SubFrame (embedded document get/set) 45 | inventory = Inventory( 46 | gold=1000, 47 | skulls=100 48 | ) 49 | 50 | cave = Lair( 51 | name='Cave', 52 | inventory=inventory 53 | ) 54 | 55 | assert cave.inventory.gold == 1000 56 | cave.inventory.gold += 100 57 | assert cave.inventory.gold == 1100 58 | 59 | def test_equal(mongo_client): 60 | """Should compare the equality of two Frame instances by Id""" 61 | 62 | # Create some dragons 63 | burt = Dragon( 64 | name='Burt', 65 | breed='Cold-drake' 66 | ) 67 | burt.insert() 68 | 69 | fred = Dragon( 70 | name='Fred', 71 | breed='Fire-drake' 72 | ) 73 | fred.insert() 74 | 75 | # Test equality 76 | assert burt != fred 77 | assert burt == burt 78 | 79 | def test_python_sort(mongo_client): 80 | """Should sort a list of Frame instances by their Ids""" 81 | 82 | # Create some dragons 83 | burt = Dragon( 84 | name='Burt', 85 | breed='Cold-drake' 86 | ) 87 | burt.insert() 88 | 89 | fred = Dragon( 90 | name='Fred', 91 | breed='Fire-drake' 92 | ) 93 | fred.insert() 94 | 95 | albert = Dragon( 96 | name='Albert', 97 | breed='Stone dragon' 98 | ) 99 | albert.insert() 100 | 101 | # Test sorting by Id 102 | assert sorted([albert, burt, fred]) == [burt, fred, albert] 103 | 104 | def test_to_json_type(mongo_client, example_dataset_one): 105 | """ 106 | Should return a dictionary for the document with all values converted to 107 | JSON safe types. All private fields should be excluded. 108 | """ 109 | 110 | burt = ComplexDragon.one(Q.name == 'Burt') 111 | cave = burt.lair 112 | 113 | assert burt.to_json_type() == { 114 | '_id': str(burt._id), 115 | 'name': 'Burt', 116 | 'dob': '1979-06-11 00:00:00', 117 | 'traits': ['irritable', 'narcissistic'], 118 | 'lair': { 119 | '_id': str(cave._id), 120 | 'name': 'Cave', 121 | 'inventory': { 122 | 'skulls': 100 123 | } 124 | } 125 | } 126 | 127 | def test_insert(mongo_client): 128 | """Should insert a document into the database""" 129 | 130 | # Create some convoluted data to insert 131 | inventory = Inventory( 132 | gold=1000, 133 | skulls=100 134 | ) 135 | 136 | cave = Lair( 137 | name='Cave', 138 | inventory=inventory 139 | ) 140 | cave.insert() 141 | 142 | burt = ComplexDragon( 143 | name='Burt', 144 | dob=datetime(1979, 6, 11), 145 | breed='Cold-drake', 146 | lair=cave, 147 | traits=['irritable', 'narcissistic'] 148 | ) 149 | burt.insert() 150 | 151 | # Test the document now has an Id 152 | assert burt._id is not None 153 | 154 | # Get the document from the database and check it's values 155 | burt.reload() 156 | 157 | assert burt.name == 'Burt' 158 | assert burt.dob == datetime(1979, 6, 11) 159 | assert burt.breed == 'Cold-drake' 160 | assert burt.traits == ['irritable', 'narcissistic'] 161 | assert burt.lair.name == 'Cave' 162 | assert burt.lair.inventory.gold == 1000 163 | assert burt.lair.inventory.skulls == 100 164 | 165 | # Test insert where we specify the `_id` value 166 | _id = ObjectId() 167 | albert = Dragon( 168 | _id=_id, 169 | name='Albert', 170 | breed='Stone dragon' 171 | ) 172 | albert.insert() 173 | 174 | assert albert._id == _id 175 | assert albert.name == 'Albert' 176 | assert albert.breed == 'Stone dragon' 177 | 178 | def test_unset(mongo_client, example_dataset_one): 179 | """Should unset fields against a document on the database""" 180 | 181 | # Unset name and breed 182 | burt = ComplexDragon.one(Q.name == 'Burt') 183 | burt.unset('name', 'breed') 184 | 185 | assert burt.name == None 186 | assert burt.breed == None 187 | assert 'name' not in burt.to_json_type() 188 | assert 'breed' not in burt.to_json_type() 189 | 190 | burt.reload() 191 | 192 | assert burt.name == None 193 | assert burt.breed == None 194 | assert 'name' not in burt.to_json_type() 195 | assert 'breed' not in burt.to_json_type() 196 | 197 | def test_update(mongo_client, example_dataset_one): 198 | """Should update a document on the database""" 199 | 200 | # Update all values 201 | burt = ComplexDragon.one(Q.name == 'Burt') 202 | 203 | burt.name = 'Jess' 204 | burt.breed = 'Fire-drake' 205 | burt.traits = ['gentle', 'kind'] 206 | burt.update() 207 | 208 | burt.reload() 209 | 210 | assert burt.name == 'Jess' 211 | assert burt.breed == 'Fire-drake' 212 | assert burt.traits == ['gentle', 'kind'] 213 | 214 | # Selective update 215 | burt.lair.name = 'Castle' 216 | burt.lair.inventory.gold += 100 217 | burt.lair.inventory.skulls = 0 218 | burt.lair.update('name', 'inventory.skulls') 219 | 220 | burt.reload() 221 | 222 | assert burt.lair.name == 'Castle' 223 | assert burt.lair.inventory.gold == 1000 224 | assert burt.lair.inventory.skulls == 0 225 | 226 | def test_upsert(mongo_client): 227 | """ 228 | Should update or insert a document on the database depending on whether or 229 | not it already exists. 230 | """ 231 | 232 | # Insert 233 | burt = Dragon( 234 | name='Burt', 235 | breed='Cold-drake' 236 | ) 237 | burt.upsert() 238 | id = burt._id 239 | burt.reload() 240 | 241 | # Update 242 | burt.upsert() 243 | burt.reload() 244 | 245 | assert burt._id == id 246 | 247 | # Test upsert where we specify the `_id` value 248 | _id = ObjectId() 249 | albert = Dragon( 250 | _id=_id, 251 | name='Albert', 252 | breed='Stone dragon' 253 | ) 254 | albert.upsert() 255 | 256 | assert albert._id == _id 257 | assert albert.name == 'Albert' 258 | assert albert.breed == 'Stone dragon' 259 | 260 | def test_delete(mongo_client, example_dataset_one): 261 | """Should delete a document from the database""" 262 | burt = ComplexDragon.one(Q.name == 'Burt') 263 | burt.delete() 264 | burt = burt.by_id(burt._id) 265 | 266 | assert burt is None 267 | 268 | def test_insert_many(mongo_client): 269 | """Should insert multiple documents records into the database""" 270 | 271 | # Create some convoluted data to insert 272 | burt = Dragon( 273 | name='Burt', 274 | breed='Cold-drake' 275 | ) 276 | 277 | fred = Dragon( 278 | name='Fred', 279 | breed='Fire-drake' 280 | ) 281 | 282 | albert = Dragon( 283 | name='Albert', 284 | breed='Stone dragon' 285 | ) 286 | 287 | burt.insert_many([burt, fred, albert]) 288 | 289 | # Check 3 dragons have been created 290 | assert Dragon.count() == 3 291 | 292 | # Check the details for each dragon 293 | dragons = Dragon.many(sort=[('_id', ASC)]) 294 | assert dragons[0].name == 'Burt' 295 | assert dragons[0].breed == 'Cold-drake' 296 | assert dragons[1].name == 'Fred' 297 | assert dragons[1].breed == 'Fire-drake' 298 | assert dragons[2].name == 'Albert' 299 | assert dragons[2].breed == 'Stone dragon' 300 | 301 | def test_unset_many(mongo_client, example_dataset_many): 302 | """Should unset fields against multiple documents on the database""" 303 | 304 | # Unset name and breed 305 | dragons = ComplexDragon.many(sort=[('_id', ASC)]) 306 | for dragon in dragons: 307 | dragon.unset('name', 'breed') 308 | 309 | for dragon in dragons: 310 | 311 | assert dragon.name == None 312 | assert dragon.breed == None 313 | assert 'name' not in dragon.to_json_type() 314 | assert 'breed' not in dragon.to_json_type() 315 | 316 | dragon.reload() 317 | 318 | assert dragon.name == None 319 | assert dragon.breed == None 320 | assert 'name' not in dragon.to_json_type() 321 | assert 'breed' not in dragon.to_json_type() 322 | 323 | def test_update_many(mongo_client, example_dataset_many): 324 | """Should update mulitple documents on the database""" 325 | 326 | # Select all the dragons 327 | dragons = ComplexDragon.many(sort=[('_id', ASC)]) 328 | 329 | # Give each dragon a second name 330 | for dragon in dragons: 331 | dragon.name += ' ' + dragon.name + 'son' 332 | 333 | # Update all values for all the dragons in one go 334 | ComplexDragon.update_many(dragons) 335 | 336 | # Reload all the dragons 337 | dragons = ComplexDragon.many(sort=[('_id', ASC)]) 338 | 339 | assert dragons[0].name == 'Burt Burtson' 340 | assert dragons[1].name == 'Fred Fredson' 341 | assert dragons[2].name == 'Albert Albertson' 342 | 343 | # Make various changes to the dragons only some of which we want to stick 344 | for dragon in dragons: 345 | dragon.name = dragon.name.split(' ')[0] 346 | dragon.breed = dragon.breed.replace('-', '_') 347 | dragon.breed = dragon.breed.replace(' ', '_') 348 | dragon.lair.inventory.gold += 100 349 | dragon.lair.inventory.skulls += 10 350 | 351 | # Update selected values for all the dragons in one go 352 | Lair.update_many([d.lair for d in dragons], 'inventory.gold') 353 | ComplexDragon.update_many(dragons, 'breed') 354 | 355 | # Reload all the dragons 356 | dragons = ComplexDragon.many(sort=[('_id', ASC)]) 357 | 358 | # Names should be the same 359 | assert dragons[0].name == 'Burt Burtson' 360 | assert dragons[1].name == 'Fred Fredson' 361 | assert dragons[2].name == 'Albert Albertson' 362 | 363 | # Breeds should have changed 364 | assert dragons[0].breed == 'Cold_drake' 365 | assert dragons[1].breed == 'Fire_drake' 366 | assert dragons[2].breed == 'Stone_dragon' 367 | 368 | # Gold should have changed 369 | assert dragons[0].lair.inventory.gold == 1100 370 | assert dragons[1].lair.inventory.gold == 2100 371 | assert dragons[2].lair.inventory.gold == 3100 372 | 373 | # Skulls should be the same 374 | assert dragons[0].lair.inventory.skulls == 100 375 | assert dragons[1].lair.inventory.skulls == 200 376 | assert dragons[2].lair.inventory.skulls == 300 377 | 378 | def test_delete_many(mongo_client, example_dataset_many): 379 | """Should delete mulitple documents from the database""" 380 | 381 | # Select all the dragons 382 | dragons = ComplexDragon.many() 383 | 384 | # Delete all of them :( 385 | ComplexDragon.delete_many(dragons) 386 | 387 | # Check there are no remaining dragons 388 | assert ComplexDragon.count() == 0 389 | 390 | def test_reload(mongo_client, example_dataset_one): 391 | """Should reload the current document's values from the database""" 392 | 393 | # Select Burt from the database 394 | burt = ComplexDragon.one(Q.name == 'Burt') 395 | 396 | # Change some values and reload 397 | burt.name = 'Fred' 398 | burt.lair.inventory = Inventory(gold=500, skulls=50) 399 | burt.reload() 400 | 401 | # Check Burt is himself again 402 | assert burt.name == 'Burt' 403 | assert burt.lair.inventory.gold == 1000 404 | assert burt.lair.inventory.skulls == 100 405 | 406 | # Reload with a different projection 407 | burt.reload(projection={'name': True}) 408 | 409 | # Check Burt has values for the projection specified 410 | assert burt.name == 'Burt' 411 | assert burt.breed == None 412 | assert burt.lair == None 413 | 414 | def test_by_id(mongo_client, example_dataset_many): 415 | """Should return a document by Id from the database""" 416 | 417 | # Get an Id for a dragon 418 | id = ComplexDragon.one(Q.name == 'Fred')._id 419 | 420 | # Load a dragon using the Id and make sure it's the same 421 | fred = ComplexDragon.by_id(id) 422 | 423 | assert fred.name == 'Fred' 424 | 425 | def test_count(mongo_client, example_dataset_many): 426 | """Should return a count for documents matching the given query""" 427 | 428 | # Count all dragons 429 | count = ComplexDragon.count() 430 | assert count == 3 431 | 432 | # Count dragons that are cold or fire drakes 433 | count = ComplexDragon.count(In(Q.breed, ['Cold-drake', 'Fire-drake'])) 434 | assert count == 2 435 | 436 | # Count dragons born after 1980 437 | count = ComplexDragon.count(Q.dob >= datetime(1981, 1, 1)) 438 | assert count == 1 439 | 440 | def test_ids(mongo_client, example_dataset_many): 441 | """Should return a list of ids for documents matching the given query""" 442 | 443 | # Ids for all dragons 444 | ids = ComplexDragon.ids() 445 | assert len(ids) == 3 446 | 447 | # Ids for dragons that are cold or fire drakes 448 | ids = ComplexDragon.ids(In(Q.breed, ['Cold-drake', 'Fire-drake'])) 449 | assert len(ids) == 2 450 | 451 | # Ids for dragons born after 1980 452 | ids = ComplexDragon.ids(Q.dob >= datetime(1981, 1, 1)) 453 | assert len(ids) == 1 454 | 455 | def test_one(mongo_client, example_dataset_many): 456 | """Should return a the first document that matches the given query""" 457 | 458 | # Select the first matching dragon 459 | burt = ComplexDragon.one() 460 | assert burt.name == 'Burt' 461 | 462 | # Sort the results so we select the last matching dragon 463 | albert = ComplexDragon.one(sort=[('_id', DESC)]) 464 | assert albert.name == 'Albert' 465 | 466 | # Select the first dragon who's a fire-drake 467 | fred = ComplexDragon.one(Q.breed == 'Fire-drake') 468 | assert fred.name == 'Fred' 469 | 470 | # Select a dragon with a different projection 471 | burt = ComplexDragon.one(projection={'name': True}) 472 | assert burt.name == 'Burt' 473 | assert burt.breed == None 474 | 475 | def test_many(mongo_client, example_dataset_many): 476 | """Should return all documents that match the given query""" 477 | 478 | # Select all dragons 479 | dragons = ComplexDragon.many(sort=[('_id', ASC)]) 480 | 481 | assert len(dragons) == 3 482 | assert dragons[0].name == 'Burt' 483 | assert dragons[1].name == 'Fred' 484 | assert dragons[2].name == 'Albert' 485 | 486 | # Select all dragons ordered by date of birth (youngest to oldest) 487 | dragons = ComplexDragon.many(sort=[('dob', DESC)]) 488 | 489 | assert dragons[0].name == 'Albert' 490 | assert dragons[1].name == 'Fred' 491 | assert dragons[2].name == 'Burt' 492 | 493 | # Select only dragons born after 1980 ordered by date of birth (youngest to 494 | # oldest). 495 | dragons = ComplexDragon.many( 496 | Q.dob > datetime(1980, 1, 1), 497 | sort=[('dob', DESC)] 498 | ) 499 | 500 | assert len(dragons) == 2 501 | assert dragons[0].name == 'Albert' 502 | assert dragons[1].name == 'Fred' 503 | 504 | # Select all dragons with a different projection 505 | dragons = ComplexDragon.many(sort=[('_id', ASC)], projection={'name': True}) 506 | 507 | assert dragons[0].name == 'Burt' 508 | assert dragons[0].breed == None 509 | assert dragons[1].name == 'Fred' 510 | assert dragons[1].breed == None 511 | assert dragons[2].name == 'Albert' 512 | assert dragons[2].breed == None 513 | 514 | def test_projection(mongo_client, example_dataset_one): 515 | """Should allow references and subframes to be projected""" 516 | 517 | # Select our complex dragon called burt 518 | burt = ComplexDragon.one(Q.name == 'Burt') 519 | inventory = Inventory( 520 | gold=1000, 521 | skulls=100 522 | ) 523 | 524 | # Test list of references 525 | burt.misc = Lair.many() 526 | burt.update() 527 | burt = ComplexDragon.one( 528 | Q.name == 'Burt', 529 | projection={'misc': {'$ref': Lair}} 530 | ) 531 | 532 | assert len(burt.misc) == 1 533 | assert burt.misc[0].name == 'Cave' 534 | 535 | # Test dictionary of references 536 | burt.misc = {'cave': Lair.one()} 537 | burt.update() 538 | burt = ComplexDragon.one( 539 | Q.name == 'Burt', 540 | projection={'misc': {'$ref': Lair}} 541 | ) 542 | 543 | assert len(burt.misc.keys()) == 1 544 | assert burt.misc['cave'].name == 'Cave' 545 | 546 | # Test list of sub-frames 547 | burt.misc = [inventory] 548 | burt.update() 549 | burt = ComplexDragon.one( 550 | Q.name == 'Burt', 551 | projection={'misc': {'$sub': Inventory}} 552 | ) 553 | 554 | assert len(burt.misc) == 1 555 | assert burt.misc[0].skulls == 100 556 | 557 | # Test dict of sub-frames 558 | burt.misc = {'spare': inventory} 559 | burt.update() 560 | burt = ComplexDragon.one( 561 | Q.name == 'Burt', 562 | projection={'misc': {'$sub.': Inventory}} 563 | ) 564 | 565 | assert len(burt.misc.keys()) == 1 566 | assert burt.misc['spare'].skulls == 100 567 | 568 | 569 | def test_timestamp_insert(mongo_client): 570 | """ 571 | Should assign a timestamp to the `created` and `modified` field for a 572 | document. 573 | """ 574 | 575 | # Assign a the timestamp helper to the insert event 576 | MonitoredDragon.listen('insert', MonitoredDragon.timestamp_insert) 577 | 578 | # Insert a monitored dragon in the database 579 | dragon = MonitoredDragon(name='Burt', breed='Cold-drake') 580 | now = datetime.now() 581 | now_tz = datetime.now(timezone.utc) 582 | dragon.insert() 583 | 584 | # Check the dragon has a created/modified timestamp set 585 | assert (dragon.created - now_tz) < timedelta(seconds=1) 586 | assert (dragon.modified - now_tz) < timedelta(seconds=1) 587 | 588 | # When the timestamps are reloaded whether they have associated timezones 589 | # will depend on the mongodb client settings, in the tests the client is not 590 | # timezone aware and so tests after the reload are against a naive datetime. 591 | dragon.reload() 592 | 593 | assert (dragon.created - now) < timedelta(seconds=1) 594 | assert (dragon.modified - now) < timedelta(seconds=1) 595 | 596 | def test_timestamp_update(mongo_client): 597 | """@@ Should assign a timestamp to the `modified` field for a document""" 598 | 599 | # Assign a the timestamp helper to the insert event 600 | MonitoredDragon.listen('insert', MonitoredDragon.timestamp_insert) 601 | MonitoredDragon.listen('update', MonitoredDragon.timestamp_update) 602 | 603 | # Insert a monitored dragon in the database 604 | dragon = MonitoredDragon(name='Burt', breed='Cold-drake') 605 | now = datetime.now() 606 | now_tz = datetime.now(timezone.utc) 607 | dragon.insert() 608 | 609 | # Check the dragon has a modified timestamp set 610 | assert (dragon.modified - now_tz) < timedelta(seconds=1) 611 | 612 | # When the timestamps are reloaded whether they have associated timezones 613 | # will depend on the mongodb client settings, in the tests the client is not 614 | # timezone aware and so tests after the reload are against a naive datetime. 615 | dragon.reload() 616 | 617 | assert (dragon.modified - now) < timedelta(seconds=1) 618 | 619 | # Wait a couple of seconds and then update the dragon 620 | sleep(2) 621 | now = datetime.now() 622 | now_tz = datetime.now(timezone.utc) 623 | dragon.breed = 'Fire-drake' 624 | dragon.update('breed', 'modified') 625 | 626 | # Check a new modified date has been set 627 | assert (dragon.modified - now_tz) < timedelta(seconds=1) 628 | dragon.reload() 629 | assert (dragon.modified - now) < timedelta(seconds=1) 630 | 631 | def test_cascade(mongo_client, example_dataset_many): 632 | """Should apply a cascading delete""" 633 | 634 | # Listen for delete events against dragons and delete any associated lair at 635 | # the same time. 636 | def on_deleted(sender, frames): 637 | ComplexDragon.cascade(Lair, 'lair', frames) 638 | 639 | ComplexDragon.listen('deleted', on_deleted) 640 | 641 | # Delete a dragon and check the associated lair is also deleted 642 | burt = ComplexDragon.one(Q.name == 'Burt') 643 | burt.delete() 644 | lair = Lair.by_id(burt.lair._id) 645 | assert lair == None 646 | 647 | def test_nullify(mongo_client, example_dataset_many): 648 | """Should nullify a reference field""" 649 | 650 | # Listen for delete events against lairs and nullify the lair field against 651 | # associated dragons 652 | def on_deleted(sender, frames): 653 | Lair.nullify(ComplexDragon, 'lair', frames) 654 | 655 | Lair.listen('deleted', on_deleted) 656 | 657 | # Delete a lair and check the associated field against the dragon has been 658 | # nullified. 659 | lair = Lair.one(Q.name == 'Cave') 660 | lair.delete() 661 | 662 | burt = ComplexDragon.one(Q.name == 'Burt', projection=None) 663 | assert burt.lair == None 664 | 665 | def test_pull(mongo_client, example_dataset_many): 666 | """Should pull references from a list field""" 667 | 668 | # Listen for delete events against lairs and pull any deleted lair from the 669 | # associated dragons. For the sake of the tests here we're storing multiple 670 | # lairs against the lair attribute instead of the intended one. 671 | def on_deleted(sender, frames): 672 | Lair.pull(ComplexDragon, 'lair', frames) 673 | 674 | Lair.listen('deleted', on_deleted) 675 | 676 | # List Burt stay in a few lairs 677 | castle = Lair.one(Q.name == 'Castle') 678 | burt = ComplexDragon.one(Q.name == 'Burt') 679 | burt.lair = [burt.lair, castle] 680 | burt.update() 681 | burt.reload() 682 | 683 | # Delete a lair and check the associated field against the dragon has been 684 | # nullified. 685 | lair = Lair.one(Q.name == 'Cave') 686 | lair.delete() 687 | burt.reload(projection=None) 688 | assert burt.lair == [castle._id] 689 | 690 | def test_listen(mongo_client): 691 | """Should add a callback for a signal against the class""" 692 | 693 | # Create a mocked functions for every event that can be triggered for a 694 | # frame. 695 | mock = Mock() 696 | 697 | def on_insert(sender, frames): 698 | mock.insert(sender, frames) 699 | 700 | def on_inserted(sender, frames): 701 | mock.inserted(sender, frames) 702 | 703 | def on_update(sender, frames): 704 | mock.update(sender, frames) 705 | 706 | def on_updated(sender, frames): 707 | mock.updated(sender, frames) 708 | 709 | def on_delete(sender, frames): 710 | mock.delete(sender, frames) 711 | 712 | def on_deleted(sender, frames): 713 | mock.deleted(sender, frames) 714 | 715 | # Listen for all events triggered by frames 716 | Dragon.listen('insert', on_insert) 717 | Dragon.listen('inserted', on_inserted) 718 | Dragon.listen('update', on_update) 719 | Dragon.listen('updated', on_updated) 720 | Dragon.listen('delete', on_delete) 721 | Dragon.listen('deleted', on_deleted) 722 | 723 | # Trigger all the events 724 | burt = Dragon(name='Burt', breed='Cold-drake') 725 | burt.insert() 726 | burt.breed = 'Fire-drake' 727 | burt.update() 728 | burt.delete() 729 | 730 | # Check each function was called 731 | assert mock.insert.called 732 | assert mock.inserted.called 733 | assert mock.update.called 734 | assert mock.updated.called 735 | assert mock.delete.called 736 | assert mock.deleted.called 737 | 738 | def test_stop_listening(mongo_client): 739 | """Should remove a callback for a signal against the class""" 740 | 741 | # Add an listener for the insert event 742 | mock = Mock() 743 | 744 | def on_insert(sender, frames): 745 | mock.insert(sender, frames) 746 | 747 | Dragon.listen('on_insert', on_insert) 748 | 749 | # Remove the listener for the insert event 750 | Dragon.stop_listening('on_insert', on_insert) 751 | 752 | # Insert a dragon into the database and check that the insert event handler 753 | # isn't called. 754 | burt = Dragon(name='Burt', breed='Cold-drake') 755 | burt.insert() 756 | 757 | assert mock.insert.called == False 758 | 759 | def test_get_collection(mongo_client): 760 | """Return a reference to the database collection for the class""" 761 | assert Dragon.get_collection() == mongo_client['mongoframes_test']['Dragon'] 762 | 763 | def test_get_db(mongo_client): 764 | """Return the database for the collection""" 765 | assert Dragon.get_db() == mongo_client['mongoframes_test'] 766 | 767 | def test_get_fields(mongo_client): 768 | """Return the fields for the class""" 769 | assert Dragon.get_fields() == {'_id', 'name', 'breed'} 770 | 771 | def test_get_private_fields(mongo_client): 772 | """Return the private fields for the class""" 773 | assert Dragon.get_private_fields() == {'breed'} 774 | 775 | def test_flattern_projection(): 776 | """Flattern projection""" 777 | 778 | projection, refs, subs = Lair._flatten_projection({ 779 | 'name': True, 780 | 'inventory': { 781 | '$sub': Inventory, 782 | 'gold': True, 783 | 'secret_draw': { 784 | '$sub': Inventory, 785 | 'gold': True 786 | } 787 | } 788 | }) 789 | 790 | assert projection == { 791 | 'name': True, 792 | 'inventory.gold': True, 793 | 'inventory.secret_draw.gold': True 794 | } 795 | 796 | def test_with_options(): 797 | """Flattern projection""" 798 | 799 | collection = Dragon.get_collection() 800 | 801 | with Dragon.with_options(read_preference=ReadPreference.SECONDARY): 802 | assert Dragon.get_collection().read_preference \ 803 | == ReadPreference.SECONDARY 804 | 805 | with Dragon.with_options( 806 | read_preference=ReadPreference.PRIMARY_PREFERRED): 807 | 808 | assert Dragon.get_collection().read_preference \ 809 | == ReadPreference.PRIMARY_PREFERRED 810 | 811 | assert Dragon.get_collection().read_preference \ 812 | == ReadPreference.SECONDARY 813 | 814 | assert collection.read_preference == ReadPreference.PRIMARY 815 | -------------------------------------------------------------------------------- /tests/test_pagination.py: -------------------------------------------------------------------------------- 1 | from pymongo import MongoClient 2 | import pytest 3 | from time import sleep 4 | from unittest.mock import Mock 5 | 6 | from mongoframes import * 7 | 8 | 9 | # Classes 10 | 11 | class Orc(Frame): 12 | """ 13 | An orc. 14 | """ 15 | 16 | _collection = 'Orc' 17 | _fields = {'name'} 18 | 19 | 20 | @pytest.fixture(scope='function') 21 | def example_dataset(request): 22 | """Connect to the database and create a set of example data (orcs)""" 23 | 24 | # Connect to mongodb and create a test database 25 | Frame._client = MongoClient('mongodb://localhost:27017/mongoframes_test') 26 | 27 | def fin(): 28 | # Remove the test database 29 | Frame._client.drop_database('mongoframes_test') 30 | 31 | request.addfinalizer(fin) 32 | 33 | # Create 1,000 orcs 34 | for i in range(0, 1000): 35 | Orc(name='Orc {0:04d}'.format(i)).insert() 36 | 37 | return Frame._client 38 | 39 | 40 | # Paginator tests 41 | 42 | def test_paginator(example_dataset): 43 | """Paginate all orcs""" 44 | 45 | # Paginate all orcs 46 | paginator = Paginator(Orc) 47 | 48 | # Check the results are as expected 49 | assert paginator.item_count == 1000 50 | assert paginator.page_count == 50 51 | assert paginator.page_numbers == range(1, 51) 52 | 53 | i = 0 54 | for page in paginator: 55 | for orc in page: 56 | assert orc.name == 'Orc {0:04d}'.format(i) 57 | i += 1 58 | 59 | def test_paginator_with_filter(example_dataset): 60 | """Paginate the last 100 orcs using a filter""" 61 | 62 | # Paginate all orcs 63 | paginator = Paginator(Orc, Q.name >= 'Orc 0900') 64 | 65 | # Check the results are as expected 66 | assert paginator.item_count == 100 67 | assert paginator.page_count == 5 68 | assert paginator.page_numbers == range(1, 6) 69 | 70 | i = 900 71 | for page in paginator: 72 | for orc in page: 73 | assert orc.name == 'Orc {0:04d}'.format(i) 74 | i += 1 75 | 76 | def test_paginator_with_sort(example_dataset): 77 | """Paginate all orcs but sort them in reverse""" 78 | 79 | # Paginate all orcs 80 | paginator = Paginator(Orc, sort=[('name', DESC)]) 81 | 82 | # Check the results are as expected 83 | assert paginator.item_count == 1000 84 | assert paginator.page_count == 50 85 | assert paginator.page_numbers == range(1, 51) 86 | 87 | i = 999 88 | for page in paginator: 89 | for orc in page: 90 | assert orc.name == 'Orc {0:04d}'.format(i) 91 | i -= 1 92 | 93 | def test_paginator_with_projection(example_dataset): 94 | """Paginate all orcs but use a custom projection""" 95 | 96 | # Paginate all orcs 97 | paginator = Paginator(Orc, projection={'name': False}) 98 | 99 | # Check the results are as expected 100 | assert paginator.item_count == 1000 101 | assert paginator.page_count == 50 102 | assert paginator.page_numbers == range(1, 51) 103 | 104 | for page in paginator: 105 | for orc in page: 106 | assert orc._id is not None 107 | assert orc.name is None 108 | 109 | def test_paginator_with_per_page(example_dataset): 110 | """Paginate all orcs but use a custom per page value""" 111 | 112 | # Paginate all orcs 113 | paginator = Paginator(Orc, per_page=100) 114 | 115 | # Check the results are as expected 116 | assert paginator.item_count == 1000 117 | assert paginator.page_count == 10 118 | assert paginator.page_numbers == range(1, 11) 119 | 120 | i = 0 121 | for page in paginator: 122 | for orc in page: 123 | assert orc.name == 'Orc {0:04d}'.format(i) 124 | i += 1 125 | 126 | def test_paginator_with_orphans(example_dataset): 127 | """Paginate all orcs but allow up to 2 orphan results""" 128 | 129 | # Paginate all orcs 130 | paginator = Paginator(Orc, Q.name >= 'Orc 0918', orphans=2) 131 | 132 | # Check the results are as expected 133 | assert paginator.item_count == 82 134 | assert paginator.page_count == 4 135 | assert paginator.page_numbers == range(1, 5) 136 | 137 | i = 918 138 | for page in paginator: 139 | for orc in page: 140 | assert orc.name == 'Orc {0:04d}'.format(i) 141 | i += 1 142 | 143 | # Check the last page has 22 items 144 | assert len(paginator[4]) == 22 145 | 146 | 147 | # Page tests 148 | 149 | def test_page(example_dataset): 150 | """Test the results or extracting a paginated page""" 151 | 152 | # Paginate all orcs 153 | paginator = Paginator(Orc) 154 | 155 | # First page 156 | page = paginator[1] 157 | assert page.prev == None 158 | assert page.next == 2 159 | assert page.number == 1 160 | assert len(page) == paginator.per_page 161 | 162 | i = 0 163 | for orc in page: 164 | assert orc.name == 'Orc {0:04d}'.format(i) 165 | i += 1 166 | 167 | # Second page 168 | page = paginator[2] 169 | assert page.prev == 1 170 | assert page.next == 3 171 | assert page.number == 2 172 | 173 | i = 20 174 | for orc in page.items: 175 | assert orc.name == 'Orc {0:04d}'.format(i) 176 | i += 1 177 | 178 | # Last page 179 | page = paginator[paginator.page_count] 180 | assert page.prev == paginator.page_count - 1 181 | assert page.next == None 182 | assert page.number == paginator.page_count 183 | 184 | # Find the offset of Orc 992 185 | orc = Orc.one(Q.name == 'Orc 0992') 186 | assert page.offset(orc) == 992 -------------------------------------------------------------------------------- /tests/test_queries.py: -------------------------------------------------------------------------------- 1 | from mongoframes import * 2 | 3 | 4 | def test_q(): 5 | """Should generate a field path""" 6 | 7 | # Single 8 | assert Q.foo._path == 'foo' 9 | 10 | # Path 11 | assert Q.foo.bar._path == 'foo.bar' 12 | 13 | # Path with index 14 | assert Q.foo.bar[2]._path == 'foo.bar.2' 15 | 16 | # Path with `_path` using item getter 17 | assert Q.foo.bar[2]['_path']._path == 'foo.bar.2._path' 18 | 19 | 20 | # Conditions as operator overrides 21 | 22 | def test_equal(): 23 | """Should generate an equals condition""" 24 | assert (Q.foo == 123).to_dict() == {'foo': 123} 25 | 26 | def test_greater_than_or_equal(): 27 | """Should generate a greater than or equal condition""" 28 | assert (Q.foo >= 123).to_dict() == {'foo': {'$gte': 123}} 29 | 30 | def test_greater_than(): 31 | """Should generate a greater than condition""" 32 | assert (Q.foo > 123).to_dict() == {'foo': {'$gt': 123}} 33 | 34 | def test_less_than_or_equal(): 35 | """Should generate a less than or equal condition""" 36 | assert (Q.foo <= 123).to_dict() == {'foo': {'$lte': 123}} 37 | 38 | def test_less_than(): 39 | """Should generate a less than condition""" 40 | assert (Q.foo < 123).to_dict() == {'foo': {'$lt': 123}} 41 | 42 | def test_not_equal(): 43 | """Should generate a not equals condition""" 44 | assert (Q.foo != 123).to_dict() == {'foo': {'$ne': 123}} 45 | 46 | 47 | # Conditions through functions 48 | 49 | def test_all(): 50 | """Should generate a contains all values condition""" 51 | assert All(Q.foo, [1, 2, 3]).to_dict() == {'foo': {'$all': [1, 2, 3]}} 52 | 53 | def test_elem_match(): 54 | """Should generate an element match condition""" 55 | condition = ElemMatch(Q.foo, Q > 1, Q < 5) 56 | assert condition.to_dict() == {'foo': {'$elemMatch': {'$gt': 1, '$lt': 5}}} 57 | 58 | def test_exists(): 59 | """Should generate an exists test condition""" 60 | assert Exists(Q.foo, True).to_dict() == {'foo': {'$exists': True}} 61 | 62 | def test_in(): 63 | """Should generate a contains condition""" 64 | assert In(Q.foo, [1, 2, 3]).to_dict() == {'foo': {'$in': [1, 2, 3]}} 65 | 66 | def test_not(): 67 | """Should generate a logical not condition""" 68 | assert Not(Q.foo == 123).to_dict() == {'foo': {'$not': {'$eq': 123}}} 69 | 70 | def test_not_in(): 71 | """Should generate a does not contain condition""" 72 | assert NotIn(Q.foo, [1, 2, 3]).to_dict() == {'foo': {'$nin': [1, 2, 3]}} 73 | 74 | def test_size(): 75 | """Should generate a array value size matches condition""" 76 | assert Size(Q.foo, 2).to_dict() == {'foo': {'$size': 2}} 77 | 78 | def test_type(): 79 | """Should generate a equals instance of condition""" 80 | assert Type(Q.foo, 1).to_dict() == {'foo': {'$type': 1}} 81 | 82 | 83 | # Groups 84 | 85 | def test_and(): 86 | """Should group one or more conditions by a logical and""" 87 | assert And(Q.foo == 123, Q.bar != 456).to_dict() == \ 88 | {'$and': [{'foo': 123}, {'bar': {'$ne': 456}}]} 89 | 90 | def test_or(): 91 | """Should group one or more conditions by a logical or""" 92 | assert Or(Q.foo == 123, Q.bar != 456).to_dict() == \ 93 | {'$or': [{'foo': 123}, {'bar': {'$ne': 456}}]} 94 | 95 | def test_nor(): 96 | """Should group one or more conditions by a logical nor""" 97 | assert Nor(Q.foo == 123, Q.bar != 456).to_dict() == \ 98 | {'$nor': [{'foo': 123}, {'bar': {'$ne': 456}}]} 99 | 100 | # Sorting 101 | 102 | def test_sort_by(): 103 | """Should return sort instructions for a list of `Q` instances""" 104 | assert SortBy(Q.dob.desc, Q.name) == [('dob', -1), ('name', 1)] 105 | 106 | 107 | # Utils 108 | 109 | def test_to_refs(): 110 | """Should convert all Frame instances within a value to Ids""" 111 | 112 | # Convert a single Frame instance 113 | assert to_refs(Frame(_id=1)) == 1 114 | 115 | # Convert a list of Frame instances 116 | assert to_refs([Frame(_id=1), Frame(_id=2)]) == [1, 2] 117 | 118 | # Convert a dictionary of Frame instances 119 | assert to_refs({1: Frame(_id=1), 2: Frame(_id=2)}) == {1: 1, 2: 2} 120 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py34,py35 3 | 4 | [testenv] 5 | deps= 6 | blinker>=1.4 7 | pymongo>=3 8 | pytest>=2.8.7 9 | pytest-mock>=1.1 10 | commands=py.test {posargs} --------------------------------------------------------------------------------