├── LICENSE ├── README.rst ├── gitmodel ├── __init__.py ├── conf.py ├── exceptions.py ├── fields.py ├── models.py ├── serializers │ ├── __init__.py │ ├── json.py │ └── python.py ├── test │ ├── __init__.py │ ├── fields │ │ ├── __init__.py │ │ ├── git-logo-2color.png │ │ ├── models.py │ │ └── tests.py │ ├── model │ │ ├── __init__.py │ │ ├── diff_branch.diff │ │ ├── diff_nobranch.diff │ │ ├── models.py │ │ └── tests.py │ ├── test_utils.py │ └── test_workspace.py ├── utils │ ├── __init__.py │ ├── dict.py │ ├── encoding.py │ ├── isodate.py │ └── path.py └── workspace.py ├── run-tests.py └── setup.py /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Ben Davis 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of python-gitmodel nor the names of its contributors may 12 | be used to endorse or promote products derived from this software without 13 | specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER ABOVE BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | python-gitmodel 3 | =============== 4 | A distributed, versioned data store for Python 5 | ---------------------------------------------- 6 | 7 | python-gitmodel is a framework for persisting objects using Git for versioning 8 | and remote syncing. 9 | 10 | Why? 11 | ---- 12 | According to `Git's README`_, Git is a "stupid content tracker". That means you 13 | aren't limited to storing source code in git. The goal of this project is to 14 | provide an object-level interface to use git as a schema-less data store, as 15 | well as tools that take advantage of git's powerful versioning capabilities. 16 | 17 | python-gitmodel allows you to model your data using python, and provides an 18 | easy-to-use interface for storing that data as git objects. 19 | 20 | python-gitmodel is based on `libgit2`_, a pure C implementation of the Git core 21 | methods. This means that instead of calling git commands via shell, we get 22 | to use git at native speed. 23 | 24 | What's so great about it? 25 | ------------------------- 26 | * Schema-less data store 27 | * Never lose data. History is kept forever and can be restored using git tools. 28 | * Branch and merge your production data 29 | 30 | * python-gitmodel can work with different branches 31 | * branch or tag snapshots of your data 32 | * experiment on production data using branches, for example, to test a migration 33 | 34 | * Ideal for content-driven applications 35 | 36 | Example usage 37 | ------------- 38 | Below we'll cover a use-case for a basic flat-page CMS. 39 | 40 | Basic model creation: 41 | 42 | .. code:: python 43 | 44 | from gitmodel.workspace import Workspace 45 | from gitmodel import fields 46 | 47 | ws = Workspace('path/to/my-repo/.git') 48 | 49 | class Page(ws.GitModel): 50 | slug = fields.SlugField() 51 | title = fields.CharField() 52 | content = fields.CharField() 53 | published = fields.BooleanField(default=True) 54 | 55 | The Workspace can be thought of as your git working directory. It also acts as 56 | the "porcelain" layer to pygit2's "plumbing". In contrast to a working 57 | directory, the Workspace class does not make use of the repository's INDEX and 58 | HEAD files, and instead keeps track of these in memory. 59 | 60 | Saving objects: 61 | 62 | .. code:: python 63 | 64 | page = Page(slug='example-page', title='Example Page') 65 | page.content = '

Here is an Example

Lorem Ipsum

' 66 | page.save() 67 | 68 | print(page.id) 69 | # abc99c394ab546dd9d6e3381f9c0fb4b 70 | 71 | By default, objects get an auto-ID field which saves as a python UUID hex 72 | (don't confuse these with git hashes). You can easily customize which field in 73 | your model acts as the ID field, for example: 74 | 75 | .. code:: python 76 | 77 | class Page(ws.GitModel): 78 | slug = fields.SlugField(id=True) 79 | 80 | # OR 81 | 82 | class Page(ws.GitModel): 83 | slug = fields.SlugField() 84 | 85 | class Meta: 86 | id_field = 'slug' 87 | 88 | Objects are not committed to the repository by default. They are, however, 89 | written into the object database as trees and blobs. The ``Workspace.index`` 90 | object is a ``pygit2.Tree`` that holds the uncommitted data. It's analagous to 91 | Git's index, except that the pointer is stored in memory. 92 | 93 | Creating commits is simple: 94 | 95 | .. code:: python 96 | 97 | oid = page.save(commit=True, message='Added an example page') 98 | commit = ws.repo[oid] # a pygit2.Commit object 99 | print(commit.message) 100 | 101 | You can access previous commits using pygit2, and even view diffs between two 102 | versions of an object. 103 | 104 | .. code:: python 105 | 106 | # walking commits 107 | for commit in ws.walk(): 108 | print("{}: {}".format(commit.hex, commit.message)) 109 | 110 | # get a diff between two commits 111 | head_commit = ws.branch.commit 112 | prev_commit_oid = head_commit.parents[0] 113 | print(prev_commit.diff(head_commit)) 114 | 115 | Objects can be easily retrieved by their id: 116 | 117 | .. code:: python 118 | 119 | page = Page.get('example-page') 120 | print(page.content) 121 | 122 | 123 | Caveat Emptor 124 | ------------- 125 | Git doesn't perform very well on its own. If you need your git-backed data to 126 | perform well in a production environment, you need to get it a "wingman". 127 | Since python-gitmodel can be used in a variety of ways, it's up to you to 128 | decide the best way to optimize it. 129 | 130 | Status 131 | ------ 132 | This project is no longer under active development. 133 | 134 | TODO 135 | ---- 136 | * Caching? 137 | * Indexing? 138 | * Query API? 139 | * Full documentation 140 | 141 | ------------------------------------------------------------------------------- 142 | 143 | python-gitmodel was inspired by Rick Olson's talk, "`Git, the Stupid NoSQL 144 | Database`_" and Paul Downman's `GitModel`_ for ruby. 145 | 146 | .. _Git's README: https://github.com/git/git#readme 147 | .. _libgit2: http://libgit2.github.com 148 | .. _Git, the Stupid NoSQL Database: http://git-nosql-rubyconf.heroku.com/ 149 | .. _GitModel: https://github.com/pauldowman/gitmodel/ 150 | -------------------------------------------------------------------------------- /gitmodel/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /gitmodel/conf.py: -------------------------------------------------------------------------------- 1 | DEFAULTS = { 2 | 'DEFAULT_SERIALIZER': 'gitmodel.serializers.json', 3 | 'LOCK_WAIT_TIMEOUT': 30, # in seconds 4 | 'LOCK_WAIT_INTERVAL': 1000, # in milliseconds 5 | 'DEFAULT_GIT_USER': ('gitmodel', 'gitmodel@local'), 6 | } 7 | 8 | 9 | class Config(dict): 10 | def __init__(self, defaults=None): 11 | if defaults is None: 12 | defaults = {} 13 | final_defaults = DEFAULTS.copy() 14 | final_defaults.update(DEFAULTS) 15 | super(Config, self).__init__(final_defaults) 16 | 17 | def __getattr__(self, name): 18 | try: 19 | return self[name] 20 | except KeyError: 21 | msg = "'{}' object has no attribute '{}'" 22 | raise AttributeError(msg.format(type(self).__name__, name)) 23 | 24 | def __setattr__(self, name, value): 25 | self[name] = value 26 | 27 | defaults = Config(DEFAULTS) 28 | -------------------------------------------------------------------------------- /gitmodel/exceptions.py: -------------------------------------------------------------------------------- 1 | class GitModelError(Exception): 2 | """A base exception for other gitmodel-related errors.""" 3 | pass 4 | 5 | 6 | class ConfigurationError(GitModelError): 7 | """Raised during configuration errors""" 8 | pass 9 | 10 | 11 | class UnsupportedFormat(GitModelError): 12 | """ 13 | Raised when an unsupported serialization format is requested. 14 | """ 15 | pass 16 | 17 | 18 | class FieldError(GitModelError): 19 | """ 20 | Raised when there is a configuration error with a ``Field``. 21 | """ 22 | pass 23 | 24 | 25 | class DoesNotExist(GitModelError): 26 | """ 27 | Raised when the object in question can't be found. 28 | """ 29 | pass 30 | 31 | 32 | class RepositoryError(GitModelError): 33 | """ 34 | Raises during an error while operating with the repository 35 | """ 36 | pass 37 | 38 | 39 | class RepositoryNotFound(GitModelError): 40 | """ 41 | Raises when the repository doesn't exist 42 | """ 43 | 44 | 45 | class ValidationError(GitModelError): 46 | """ 47 | Raised when an invalid value is encountered 48 | """ 49 | def __init__(self, msg_or_code, field=None, **kwargs): 50 | self.field = field 51 | self.msg_or_code = msg_or_code 52 | if self.field: 53 | msg = self.field.get_error_message(msg_or_code, 54 | default=msg_or_code, 55 | **kwargs) 56 | else: 57 | msg = msg_or_code 58 | super(ValidationError, self).__init__(msg) 59 | 60 | 61 | class IntegrityError(GitModelError): 62 | """ 63 | Raised when a save results in duplicate values 64 | """ 65 | pass 66 | 67 | 68 | class ModelNotFound(Exception): 69 | """ 70 | Raised during deserialization if the model class no longer exists 71 | """ 72 | pass 73 | -------------------------------------------------------------------------------- /gitmodel/fields.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import decimal 3 | import os 4 | import re 5 | import uuid 6 | from datetime import datetime, date, time 7 | from StringIO import StringIO 8 | from urlparse import urlparse 9 | 10 | import pygit2 11 | 12 | from gitmodel.utils import isodate, json 13 | from gitmodel.exceptions import ValidationError, FieldError 14 | 15 | INVALID_PATH_CHARS = ('/', '\000') 16 | 17 | 18 | class NOT_PROVIDED: 19 | def __str__(self): 20 | return 'No default provided.' 21 | 22 | 23 | class Field(object): 24 | """The base implementation of a field used by a GitModel class.""" 25 | creation_counter = 0 26 | default_error_messages = { 27 | 'required': 'is required', 28 | 'invalid_path': 'may only contain valid path characters', 29 | } 30 | serializable = True 31 | empty_value = None 32 | 33 | def __init__(self, name=None, id=False, default=NOT_PROVIDED, 34 | required=True, readonly=False, unique=False, serialize=True, 35 | autocreated=False, error_messages=None): 36 | 37 | self.model = None 38 | self.name = name 39 | self.id = id 40 | self._default = default 41 | self.required = required 42 | self.readonly = readonly 43 | self.value = self.empty_value 44 | self.unique = unique 45 | self.serializeable = self.serializable and serialize 46 | self.autocreated = autocreated 47 | 48 | # update error_messages using default_error_messages from all parents 49 | #NEEDS-TEST 50 | messages = {} 51 | for c in reversed(type(self).__mro__): 52 | messages.update(getattr(c, 'default_error_messages', {})) 53 | messages.update(error_messages or {}) 54 | self.error_messages = messages 55 | 56 | # store the creation index in the "creation_counter" of the field 57 | self.creation_counter = Field.creation_counter 58 | # increment the global counter 59 | Field.creation_counter += 1 60 | 61 | def contribute_to_class(self, cls, name): 62 | field = self 63 | field.name = name 64 | # if this field has already been assigned to a model, assign a shallow 65 | # copy of it instead. 66 | if field.model: 67 | field = copy.copy(field) 68 | field.model = cls 69 | cls._meta.add_field(field) 70 | 71 | def has_default(self): 72 | """Returns a boolean of whether this field has a default value.""" 73 | return self._default is not NOT_PROVIDED 74 | 75 | @property 76 | def default(self): 77 | """Returns the default value for the field.""" 78 | if self.has_default(): 79 | if callable(self._default): 80 | return self._default() 81 | return self._default 82 | return 83 | 84 | def empty(self, value): 85 | """Returns True if value is considered an empty value for this field""" 86 | return value is None or value == self.empty_value 87 | 88 | def __cmp__(self, other): 89 | # This is needed because bisect does not take a comparison function. 90 | return cmp(self.creation_counter, other.creation_counter) 91 | 92 | def to_python(self, value): 93 | """ 94 | Coerces the data into a valid python value. Raises ValidationError if 95 | the value cannot be coerced. 96 | """ 97 | return value 98 | 99 | def get_raw_value(self, model_instance): 100 | """ 101 | Used during the model's clean_fields() method. There is usually no 102 | need to override this unless the field is a descriptor. 103 | """ 104 | return getattr(model_instance, self.name) 105 | 106 | def validate(self, value, model_instance): 107 | """ 108 | Validates a coerced value (ie, passed through to_python) and throws a 109 | ValidationError if invalid. 110 | """ 111 | if self.required and self.empty(value): 112 | raise ValidationError('required', self) 113 | 114 | if self.id and any(c in value for c in INVALID_PATH_CHARS): 115 | raise ValidationError('invalid_path', self) 116 | 117 | def clean(self, value, model_instance): 118 | """ 119 | Validates the given value and returns its "cleaned" value as an 120 | appropriate Python object. 121 | 122 | Raises ValidationError for any errors. 123 | """ 124 | value = self.to_python(value) 125 | self.validate(value, model_instance) 126 | return value 127 | 128 | def serialize(self, obj): 129 | """ 130 | Returns a python value used for serialization. 131 | """ 132 | value = getattr(obj, self.name) 133 | return self.to_python(value) 134 | 135 | def deserialize(self, data, value): 136 | """ 137 | Returns the proper value just after deserialization 138 | """ 139 | return self.to_python(value) 140 | 141 | def get_error_message(self, error_code, default='', **kwargs): 142 | msg = self.error_messages.get(error_code, default) 143 | kwargs['field'] = self 144 | msg = msg.format(**kwargs) 145 | return '"{name}" {err}'.format(name=self.name, err=msg) 146 | 147 | def post_save(self, value, model_instance, commit=False): 148 | """ 149 | Called after the model has been saved, just before it is committed. 150 | The commit argument is passed through from GitModel.save() 151 | """ 152 | pass 153 | 154 | 155 | class CharField(Field): 156 | """ 157 | A text field of arbitrary length. 158 | """ 159 | empty_value = '' 160 | 161 | def to_python(self, value): 162 | if value is None and not self.required: 163 | return self.empty_value 164 | 165 | if value is None: 166 | return None 167 | 168 | return unicode(value) 169 | 170 | 171 | class SlugField(CharField): 172 | default_error_messages = { 173 | 'invalid_slug': ('must contain only letters, numbers, underscores and ' 174 | 'dashes') 175 | } 176 | 177 | def validate(self, value, model_instance): 178 | super(SlugField, self).validate(value, model_instance) 179 | slug_re = re.compile(r'^[-\w]+$') 180 | if not slug_re.match(value): 181 | raise ValidationError('invalid_slug', self) 182 | 183 | 184 | class EmailField(CharField): 185 | default_error_messages = { 186 | 'invalid_email': 'must be a valid e-mail address' 187 | } 188 | 189 | def validate(self, value, model_instance): 190 | super(EmailField, self).validate(value, model_instance) 191 | 192 | email_re = re.compile( 193 | r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+" 194 | r"(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*" # dot-atom 195 | r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]' 196 | r'|\\[\001-011\013\014\016-\177])*"' # quoted-string 197 | r')@(?:[A-Z0-9](?:[A-Z0-9-]{0,61}' 198 | r'[A-Z0-9])?\.)+[A-Z]{2,6}\.?$', # domain 199 | re.IGNORECASE) 200 | 201 | if not email_re.match(value): 202 | raise ValidationError('invalid_email', self) 203 | 204 | 205 | class URLField(CharField): 206 | default_error_messages = { 207 | 'invalid_url': 'must be a valid URL', 208 | 'invalid_scheme': 'scheme must be one of {schemes}' 209 | } 210 | 211 | def __init__(self, **kwargs): 212 | """ 213 | ``schemes`` is a list of URL schemes to which this field should be 214 | restricted. Raises validation error if url scheme is not in this list. 215 | Otherwise, any scheme is allowed. 216 | """ 217 | self.schemes = kwargs.pop('schemes', None) 218 | super(URLField, self).__init__(self, **kwargs) 219 | 220 | def validate(self, value, model_instance): 221 | super(URLField, self).validate(value, model_instance) 222 | if self.empty(value): 223 | return 224 | parsed = urlparse(value) 225 | if not all((parsed.scheme, parsed.hostname)): 226 | raise ValidationError('invalid_url', self) 227 | if self.schemes and parsed.scheme.lower() not in self.schemes: 228 | schemes = ', '.join(self.schemes) 229 | raise ValidationError('invalid_scheme', self, schemes=schemes) 230 | 231 | 232 | class BlobFieldDescriptor(object): 233 | def __init__(self, field): 234 | self.field = field 235 | self.data = None 236 | 237 | def __get__(self, instance, instance_type=None): 238 | if self.data is None: 239 | workspace = instance._meta.workspace 240 | path = self.field.get_data_path(instance) 241 | try: 242 | blob = workspace.index[path].oid 243 | except KeyError: 244 | return None 245 | self.data = StringIO(workspace.repo[blob].data) 246 | return self.data 247 | 248 | def __set__(self, instance, value): 249 | if isinstance(value, type(self)): 250 | # re-set data to read from repo on next __get__ 251 | self.data = None 252 | elif value is None: 253 | self.data = None 254 | elif hasattr(value, 'read'): 255 | self.data = StringIO(value.read()) 256 | else: 257 | self.data = StringIO(value) 258 | 259 | 260 | class BlobField(Field): 261 | """ 262 | A field for storing larger amounts of data with a model. This is stored as 263 | its own git blob within the repository, and added as a file entry under the 264 | same path as the data.json for that instance. 265 | """ 266 | serializable = False 267 | 268 | def to_python(self, value): 269 | if hasattr(value, 'read'): 270 | return value 271 | if value is None: 272 | return None 273 | return StringIO(value) 274 | 275 | def post_save(self, value, instance, commit=False): 276 | if value is None: 277 | return 278 | workspace = instance._meta.workspace 279 | path = self.get_data_path(instance) 280 | # the value should already be coerced to a file-like object by now 281 | content = value.read() 282 | workspace.add_blob(path, content) 283 | 284 | def get_data_path(self, instance): 285 | path = os.path.dirname(instance.get_data_path()) 286 | path = os.path.join(path, self.name) 287 | return '{0}.data'.format(path) 288 | 289 | def contribute_to_class(self, cls, name): 290 | super(BlobField, self).contribute_to_class(cls, name) 291 | setattr(cls, name, BlobFieldDescriptor(self)) 292 | 293 | def deserialize(self, data, value): 294 | return BlobFieldDescriptor(self) 295 | 296 | 297 | class IntegerField(Field): 298 | """ 299 | An integer field. 300 | """ 301 | default_error_messages = { 302 | 'invalid_int': 'must be an integer' 303 | } 304 | 305 | def to_python(self, value): 306 | if value is None: 307 | return None 308 | # we should only allow whole numbers. so we coerce to float first, then 309 | # check to see if it's divisible by 1 without a remainder 310 | try: 311 | value = float(value) 312 | except ValueError: 313 | raise ValidationError('invalid_int', self) 314 | if value % 1 != 0: 315 | raise ValidationError('invalid_int', self) 316 | return int(value) 317 | 318 | 319 | class UUIDField(CharField): 320 | """ 321 | A CharField which uses a globally-unique identifier as its default value 322 | """ 323 | @property 324 | def default(self): 325 | return uuid.uuid4().hex 326 | 327 | 328 | class FloatField(Field): 329 | default_error_messages = { 330 | 'invalid_float': 'must be a floating-point number' 331 | } 332 | 333 | def to_python(self, value): 334 | if value is None: 335 | return None 336 | try: 337 | return float(value) 338 | except ValueError: 339 | raise ValidationError('invalid_float', self) 340 | 341 | 342 | class DecimalField(Field): 343 | default_error_messages = { 344 | 'invalid_decimal': 'must be a numeric value', 345 | } 346 | 347 | def __init__(self, max_digits=None, decimal_places=None, **kwargs): 348 | self.max_digits = max_digits 349 | self.decimal_places = decimal_places 350 | super(DecimalField, self).__init__(**kwargs) 351 | 352 | def to_python(self, value): 353 | if value is None: 354 | return None 355 | if type(value) == float: 356 | value = str(value) 357 | try: 358 | return decimal.Decimal(value) 359 | except decimal.InvalidOperation: 360 | raise ValidationError('invalid_decimal', self) 361 | 362 | 363 | class BooleanField(Field): 364 | def __init__(self, nullable=False, **kwargs): 365 | self.nullable = nullable 366 | super(BooleanField, self).__init__(**kwargs) 367 | 368 | def to_python(self, value): 369 | if value is None and self.nullable: 370 | return None 371 | return bool(value) 372 | 373 | 374 | class DateField(Field): 375 | default_error_messages = { 376 | 'invalid_format': 'must be in the format of YYYY-MM-DD', 377 | 'invalid': 'must be a valid date', 378 | } 379 | 380 | def to_python(self, value): 381 | if value is None: 382 | return value 383 | if isinstance(value, datetime): 384 | return value.date() 385 | if isinstance(value, date): 386 | return value 387 | 388 | if isinstance(value, basestring): 389 | try: 390 | return isodate.parse_iso_date(value) 391 | except isodate.InvalidFormat: 392 | raise ValidationError('invalid_format', self) 393 | except isodate.InvalidDate: 394 | raise ValidationError('invalid', self) 395 | 396 | 397 | class DateTimeField(Field): 398 | default_error_messages = { 399 | 'invalid_format': 'must be in the format of YYYY-MM-DD HH:MM[:SS]', 400 | 'invalid': 'must be a valid date/time' 401 | } 402 | 403 | def to_python(self, value): 404 | if value is None: 405 | return value 406 | if isinstance(value, datetime): 407 | return value 408 | if isinstance(value, date): 409 | return datetime(value.year, value.month, value.day) 410 | 411 | if isinstance(value, basestring): 412 | try: 413 | return isodate.parse_iso_datetime(value) 414 | except isodate.InvalidFormat: 415 | # we also accept a date-only string 416 | try: 417 | return isodate.parse_iso_date(value) 418 | except isodate.InvalidFormat: 419 | raise ValidationError('invalid_format', self) 420 | except isodate.InvalidDate: 421 | raise ValidationError('invalid', self) 422 | 423 | 424 | class TimeField(Field): 425 | default_error_messages = { 426 | 'invalid_format': 'must be in the format of HH:MM[:SS]', 427 | 'invalid': 'must be a valid time' 428 | } 429 | 430 | def to_python(self, value): 431 | if value is None: 432 | return value 433 | if isinstance(value, time): 434 | return value 435 | 436 | if isinstance(value, basestring): 437 | try: 438 | return isodate.parse_iso_time(value) 439 | except isodate.InvalidFormat: 440 | raise ValidationError('invalid_format', self) 441 | except isodate.InvalidDate: 442 | raise ValidationError('invalid', self) 443 | 444 | 445 | class RelatedFieldDescriptor(object): 446 | def __init__(self, field): 447 | self.field = field 448 | self.id = None 449 | 450 | def __get__(self, instance, instance_type=None): 451 | from gitmodel import models 452 | if instance is None: 453 | return self 454 | value = instance.__dict__[self.field.name] 455 | if value is None or isinstance(value, models.GitModel): 456 | return value 457 | return self.field.to_model.get(value) 458 | 459 | def __set__(self, instance, value): 460 | instance.__dict__[self.field.name] = value 461 | 462 | 463 | class RelatedField(Field): 464 | def __init__(self, model, **kwargs): 465 | self._to_model = model 466 | super(RelatedField, self).__init__(**kwargs) 467 | 468 | @property 469 | def to_model(self): 470 | if not self.workspace: 471 | return self._to_model 472 | 473 | # if to_model is a string, it must be registered on the same workspace 474 | if isinstance(self._to_model, basestring): 475 | if not self.workspace.models.get(self._to_model): 476 | msg = "Could not find model '{0}'".format(self._to_model) 477 | raise FieldError(msg) 478 | return self.workspace.models[self._to_model] 479 | 480 | # if the model has already been registered with a workspace, use as-is 481 | if hasattr(self._to_model, '_meta'): 482 | return self._to_model 483 | 484 | # otherwise, check on our own workspace 485 | if self.workspace.models.get(self._to_model.__name__): 486 | return self.workspace.models[self._to_model.__name__] 487 | 488 | # if it's a model but hasn't been registered, register it on the same 489 | # workspace. 490 | return self.workspace.register_model(self.to_model) 491 | 492 | def to_python(self, value): 493 | from gitmodel import models 494 | if isinstance(value, models.GitModel): 495 | return value.get_id() 496 | return value 497 | 498 | def serialize(self, obj): 499 | value = obj.__dict__[self.name] 500 | return self.to_python(value) 501 | 502 | def contribute_to_class(self, cls, name): 503 | super(RelatedField, self).contribute_to_class(cls, name) 504 | if hasattr(cls, '_meta'): 505 | self.workspace = cls._meta.workspace 506 | setattr(cls, name, RelatedFieldDescriptor(self)) 507 | 508 | 509 | class GitObjectFieldDescriptor(object): 510 | def __init__(self, field): 511 | self.field = field 512 | self.oid = None 513 | 514 | def __get__(self, instance, instance_type=None): 515 | if instance is None: 516 | return self 517 | value = instance.__dict__[self.field.name] 518 | if value is None or isinstance(value, pygit2.Object): 519 | return value 520 | return instance._meta.workspace.repo[value] 521 | 522 | def __set__(self, instance, value): 523 | if isinstance(value, pygit2.Object): 524 | value = value.oid.hex 525 | elif isinstance(value, pygit2.Oid): 526 | value = value.hex 527 | instance.__dict__[self.field.name] = value 528 | 529 | 530 | class GitObjectField(CharField): 531 | """ 532 | Acts as a reference to a git object. This field stores the OID of the 533 | object. Returns the actual object when accessed as a property. 534 | """ 535 | default_error_messages = { 536 | 'invalid_oid': "must be a valid git OID or pygit2 Object", 537 | 'invalid_type': "must point to a {type}", 538 | } 539 | 540 | def __init__(self, **kwargs): 541 | """ 542 | If ``type`` is given, restricts the field to a specific type, and will 543 | raise a ValidationError during .validate() if an invalid type is given. 544 | 545 | ``type`` can be a valid pygit2 git object class, such as pygit2.Blob, 546 | pygit2.Commit, or pygit2.Tree. Any object type that can be resolved 547 | from a git oid is valid. 548 | """ 549 | self.type = kwargs.pop('type', None) 550 | super(GitObjectField, self).__init__(**kwargs) 551 | 552 | def to_python(self, value): 553 | if not isinstance(value, (basestring, pygit2.Oid, pygit2.Object)): 554 | raise ValidationError('invalid_object', self) 555 | if isinstance(value, pygit2.Oid): 556 | return value.hex 557 | return value 558 | 559 | def clean(self, value, model_instance): 560 | raw_value = model_instance.__dict__[self.name] 561 | return super(GitObjectField, self).clean(raw_value, model_instance) 562 | 563 | def serialize(self, obj): 564 | value = obj.__dict__[self.name] 565 | return self.to_python(value) 566 | 567 | def contribute_to_class(self, cls, name): 568 | super(GitObjectField, self).contribute_to_class(cls, name) 569 | setattr(cls, name, GitObjectFieldDescriptor(self)) 570 | 571 | def get_raw_value(self, model_instance): 572 | return model_instance.__dict__[self.name] 573 | 574 | def validate(self, value, model_instance): 575 | super(GitObjectField, self).validate(value, model_instance) 576 | oid = model_instance.__dict__[self.name] 577 | try: 578 | obj = model_instance._meta.workspace.repo[oid] 579 | except (ValueError, KeyError): 580 | raise ValidationError('invalid_oid', self) 581 | if self.type and not isinstance(obj, self.type): 582 | raise ValidationError('invalid_type', self, 583 | type=self.type.__name__) 584 | 585 | 586 | class JSONField(CharField): 587 | def to_python(self, value): 588 | if value is None: 589 | return None 590 | if isinstance(value, dict): 591 | return value 592 | try: 593 | return json.loads(value) 594 | except ValueError, e: 595 | raise ValidationError(e) 596 | -------------------------------------------------------------------------------- /gitmodel/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | from bisect import bisect 3 | from contextlib import contextmanager 4 | from importlib import import_module 5 | 6 | import decorator 7 | 8 | from gitmodel import exceptions 9 | from gitmodel import fields 10 | from gitmodel import utils 11 | 12 | 13 | class GitModelOptions(object): 14 | """ 15 | An options class for ``GitModel``. 16 | """ 17 | # attributes that can be overridden in a model's options ("Meta" class) 18 | meta_opts = ('abstract', 'data_filename', 'get_data_path', 'id_attr') 19 | 20 | # reserved attributes 21 | reserved = ('oid',) 22 | 23 | def __init__(self, meta, workspace): 24 | self.meta = meta 25 | self.workspace = workspace 26 | self.abstract = False 27 | self.local_fields = [] 28 | self.local_many_to_many = [] 29 | self.model_name = None 30 | self.parents = [] 31 | self.id_attr = None 32 | self.data_filename = 'data.json' 33 | self._serializer = None 34 | 35 | @property 36 | def serializer(self): 37 | if self._serializer is None: 38 | from gitmodel.conf import defaults 39 | default_serializer = defaults.DEFAULT_SERIALIZER 40 | if self.workspace is not None: 41 | default_serializer = self.workspace.config.DEFAULT_SERIALIZER 42 | self._serializer = import_module(default_serializer) 43 | return self._serializer 44 | 45 | def contribute_to_class(self, cls, name): 46 | cls._meta = self 47 | 48 | # Default values for these options 49 | self.model_name = cls.__name__ 50 | 51 | # Apply overrides from Meta 52 | if self.meta: 53 | # Ignore private attributes 54 | for name in dir(self.meta): 55 | if name.startswith('_') or name not in self.meta_opts: 56 | continue 57 | 58 | value = getattr(self.meta, name) 59 | # if attr is a function, bind it to this instance 60 | if not isinstance(value, type) and hasattr(value, '__call__'): 61 | value = value.__get__(self) 62 | setattr(self, name, value) 63 | 64 | self._declared_meta = self.meta 65 | del self.meta 66 | 67 | def get_data_path(self, object_id): 68 | """ 69 | Default method for building the path name for a given id. 70 | 71 | This is used by a model instance's get_data_path() method, which simply 72 | passes the instance id. 73 | """ 74 | model_name = self.model_name.lower() 75 | return os.path.join(model_name, unicode(object_id), self.data_filename) 76 | 77 | def add_field(self, field): 78 | """ Insert a field into the fields list in correct order """ 79 | if field.name in self.reserved: 80 | raise exceptions.FieldError("{} is a reserved name and cannot be" 81 | "used as a field name.") 82 | # bisect calls field.__cmp__ which uses field.creation_counter to 83 | # maintain the correct order 84 | position = bisect(self.local_fields, field) 85 | self.local_fields.insert(position, field) 86 | 87 | # invalidate the field cache 88 | if hasattr(self, '_field_cache'): 89 | del self._field_cache 90 | 91 | @property 92 | def fields(self): 93 | """ 94 | Returns a list of field objects available for this model (including 95 | through parent models). 96 | 97 | Callers are not permitted to modify this list, since it's a reference 98 | to this instance (not a copy) 99 | """ 100 | # get cached field names. if not cached, then fill the cache. 101 | if not hasattr(self, '_field_cache'): 102 | self._fill_fields_cache() 103 | return self._field_cache 104 | 105 | def get_field(self, name): 106 | for field in self.fields: 107 | if field.name == name: 108 | return field 109 | msg = "Field not '{}' not found on model '{}'" 110 | raise exceptions.FieldError(msg.format(name, self.model_name)) 111 | 112 | def _fill_fields_cache(self): 113 | """ 114 | Caches all fields, including fields from parents. 115 | """ 116 | cache = [] 117 | has_id_attr = self.id_attr or any(f.id for f in self.local_fields) 118 | for parent in self.parents: 119 | for field in parent._meta.fields: 120 | # skip if overridden locally 121 | if field.name in (f.name for f in self.local_fields): 122 | continue 123 | # only add id field if not specified locally 124 | if field.id and has_id_attr: 125 | continue 126 | cache.append(field) 127 | cache.extend(self.local_fields) 128 | self._field_cache = tuple(cache) 129 | 130 | def _prepare(self, model): 131 | # set up id field 132 | if self.id_attr is None: 133 | declared_id_fields = [f for f in self.fields if f.id] 134 | if len(declared_id_fields) > 1: 135 | msg = "You may only have one id field per model" 136 | raise exceptions.ConfigurationError(msg) 137 | elif len(declared_id_fields) == 1: 138 | self.id_attr = declared_id_fields[0].name 139 | else: 140 | # add an automatic uuid field 141 | auto = fields.UUIDField(id=True, autocreated=True) 142 | # add to the beginning of the fields list 143 | auto.creation_counter = -1 144 | model.add_to_class('id', auto) 145 | self.id_attr = 'id' 146 | 147 | 148 | class DeclarativeMetaclass(type): 149 | def __new__(cls, name, bases, attrs): 150 | super_new = super(DeclarativeMetaclass, cls).__new__ 151 | 152 | parents = [b for b in bases if isinstance(b, DeclarativeMetaclass)] 153 | parents.reverse() 154 | 155 | if not parents: 156 | # Don't do anything special for the base GitModel 157 | return super_new(cls, name, bases, attrs) 158 | 159 | # workspace that will be passed to GitModelOptions 160 | workspace = attrs.pop('__workspace__', None) 161 | 162 | # inherit parent workspace if not provided 163 | if not workspace: 164 | if len(parents) > 0 and hasattr(parents[0], '_meta'): 165 | workspace = parents[0]._meta.workspace 166 | 167 | # don't do anything special for GitModels without a workspace 168 | if not workspace: 169 | return super_new(cls, name, bases, attrs) 170 | 171 | # Create the new class, while leaving out the declared attributes 172 | # which will be added later 173 | module = attrs.pop('__module__') 174 | options_cls = attrs.pop('__optclass__', None) 175 | new_class = super_new(cls, name, bases, {'__module__': module}) 176 | 177 | # grab the declared Meta 178 | meta = attrs.pop('Meta', None) 179 | base_meta = None 180 | if parents and hasattr(parents[0], '_meta'): 181 | base_meta = parents[0]._meta 182 | 183 | # Add _meta to the new class. The _meta property is an instance of 184 | # GitModelOptions, based off of the optional declared "Meta" class 185 | if options_cls is None: 186 | if base_meta: 187 | options_cls = type(base_meta) 188 | else: 189 | options_cls = GitModelOptions 190 | 191 | if meta is None: 192 | # if meta is not declared, use the closest parent's meta 193 | meta = next((p._meta._declared_meta for p in parents if 194 | hasattr(p, '_meta') and p._meta._declared_meta), None) 195 | # don't inherit the abstract property 196 | if hasattr(meta, 'abstract'): 197 | meta.abstract = False 198 | 199 | opts = options_cls(meta, workspace) 200 | 201 | new_class.add_to_class('_meta', opts) 202 | 203 | # Add all attributes to the class 204 | for obj_name, obj in attrs.items(): 205 | new_class.add_to_class(obj_name, obj) 206 | 207 | # Handle parents 208 | for parent in parents: 209 | if not hasattr(parent, '_meta'): 210 | # Ignore parents that have no _meta 211 | continue 212 | new_class._check_parent_fields(parent) 213 | new_class._meta.parents.append(parent) 214 | 215 | new_class._prepare() 216 | 217 | # make sure the model is registered in its workspace 218 | workspace.register_model(new_class) 219 | 220 | return new_class 221 | 222 | def _check_parent_fields(cls, parent, child=None): 223 | """ 224 | Checks a parent class's inheritance chain for field conflicts 225 | """ 226 | if child is None: 227 | child = cls 228 | 229 | local_field_names = [f.name for f in child._meta.local_fields] 230 | # Check for duplicate field definitions in parent 231 | for field in parent._meta.local_fields: 232 | if not field.autocreated and field.name in local_field_names: 233 | msg = ('Duplicate field name "{0}" in {1!r} already exists in ' 234 | 'parent model {2!r}') 235 | msg = msg.format(field.name, child.__name__, parent.__name__) 236 | raise exceptions.FieldError(msg) 237 | 238 | # check base's inheritance chain 239 | for p in parent._meta.parents: 240 | parent._check_parent_fields(p, child) 241 | 242 | def _prepare(cls): 243 | """ 244 | Prepares the class once cls._meta has been populated. 245 | """ 246 | opts = cls._meta 247 | opts._prepare(cls) 248 | 249 | # Give the class a docstring 250 | if cls.__doc__ is None: 251 | fields = ', '.join(f.name for f in opts.fields) 252 | cls.__doc__ = "{}({})".format(cls.__name__, fields) 253 | 254 | def add_to_class(cls, name, value): 255 | """ 256 | If the given value defines a ``contribute_to_class`` method, that will 257 | be called. Otherwise, this is an alias to setattr. This allows objects 258 | to have control over how they're added to a class during its creation. 259 | """ 260 | if hasattr(value, 'contribute_to_class'): 261 | value.contribute_to_class(cls, name) 262 | else: 263 | setattr(cls, name, value) 264 | 265 | 266 | @decorator.decorator 267 | def concrete(func, self, *args, **kwargs): 268 | """ 269 | Causes a model's method to require a non-abstract, workspace-bound model. 270 | """ 271 | # decorator should work for classmethods as well as instance methods 272 | model = self 273 | if not isinstance(model, type): 274 | model = type(self) 275 | if not hasattr(model, '_meta'): 276 | msg = ("Cannot call {0.__name__}.{1.__name__}() because {0!r} " 277 | "has not been registered with a workspace") 278 | raise exceptions.GitModelError(msg.format(model, func)) 279 | if model._meta.abstract: 280 | msg = "Cannot call {1.__name__}() on abstract model {0.__name__} " 281 | raise exceptions.GitModelError(msg.format(model, func)) 282 | return func(self, *args, **kwargs) 283 | 284 | 285 | class GitModel(object): 286 | __metaclass__ = DeclarativeMetaclass 287 | __workspace__ = None 288 | 289 | @concrete 290 | def __init__(self, **kwargs): 291 | """ 292 | Create an instance of a GitModel. 293 | 294 | Field name/value pairs may be provided as kwargs to set the initial 295 | value for those fields. If "oid" is provided in kwargs, it is assumed 296 | that this model is being instantiated from an existing instance in the 297 | git repository. Deserializing a model will automatically set this oid. 298 | """ 299 | self._oid = kwargs.pop('oid', None) 300 | 301 | # To keep things simple, we only accept attribute values as kwargs 302 | # Check for fields in kwargs 303 | for field in self._meta.fields: 304 | # if a field value was given in kwargs, get its value, otherwise, 305 | # get the field's default value 306 | if kwargs: 307 | try: 308 | val = kwargs.pop(field.name) 309 | except KeyError: 310 | val = field.default 311 | else: 312 | val = field.default 313 | setattr(self, field.name, val) 314 | 315 | # Handle any remaining keyword arguments 316 | if kwargs: 317 | # only set attrs for properties that already exist on the class 318 | for prop in kwargs.keys(): 319 | try: 320 | if isinstance(getattr(type(self), prop), property): 321 | setattr(self, prop, kwargs.pop(prop)) 322 | except AttributeError: 323 | pass 324 | if kwargs: 325 | msg = "'{0}' is an invalid keyword argument for this function" 326 | raise TypeError(msg.format(kwargs.keys()[0])) 327 | 328 | super(GitModel, self).__init__() 329 | 330 | def __repr__(self): 331 | try: 332 | u = unicode(self) 333 | except (UnicodeEncodeError, UnicodeDecodeError): 334 | u = '[Bad Unicode Data]' 335 | return u'<{0}: {1}>'.format(self._meta.model_name, u) 336 | 337 | def __str__(self): 338 | if hasattr(self, '__unicode__'): 339 | return unicode(self).encode('utf-8') 340 | return '{0} object'.format(self._meta.model_name) 341 | 342 | def save(self, commit=False, **commit_info): 343 | # make sure model has clean data 344 | self.full_clean() 345 | 346 | # if this is new, make sure we don't overwrite an existing instance by 347 | # accident 348 | if not self.oid: 349 | id = self.get_id() 350 | try: 351 | type(self).get(id) 352 | except exceptions.DoesNotExist: 353 | pass 354 | else: 355 | err = 'A {} instance already exists with id "{}"'.format( 356 | type(self).__name__, self.get_id()) 357 | raise exceptions.IntegrityError(err) 358 | 359 | serialized = self._meta.serializer.serialize(self) 360 | 361 | workspace = self._meta.workspace 362 | 363 | # only allow commit-during-save if workspace doesn't have pending 364 | # changes. 365 | if commit and workspace.has_changes(): 366 | msg = "Repository has pending changes. Cannot save-commit until "\ 367 | "pending changes have been comitted." 368 | raise exceptions.RepositoryError(msg) 369 | 370 | # create the git object and set the instance oid 371 | self._oid = workspace.add_blob(self.get_data_path(), serialized) 372 | 373 | # go through fields that have their own save handler 374 | for field in self._meta.fields: 375 | value = getattr(self, field.name) 376 | field.post_save(value, self, commit) 377 | 378 | # if our path has changed, remove the old path. This generally only 379 | # happens with a custom mutable id_attr. 380 | old_path = getattr(self, '_current_path', None) 381 | new_path = self.get_data_path() 382 | if old_path and old_path != new_path: 383 | rmpath = os.path.dirname(old_path) 384 | self._meta.workspace.remove(rmpath) 385 | 386 | self._current_path = self.get_data_path() 387 | 388 | if commit: 389 | return workspace.commit(**commit_info) 390 | 391 | def get_id(self): 392 | return getattr(self, self._meta.id_attr) 393 | 394 | def get_data_path(self): 395 | id = unicode(self.get_id()) 396 | return self._meta.get_data_path(id) 397 | 398 | @property 399 | def oid(self): 400 | """ 401 | The OID of the git blob used to store this object. This is a read-only 402 | property. 403 | """ 404 | return self._oid 405 | 406 | def clean(self): 407 | """ 408 | Hook for doing any extra model-secific validation after fields have 409 | been cleaned. 410 | """ 411 | pass 412 | 413 | def clean_fields(self): 414 | """ 415 | Validates all fields on the model. 416 | """ 417 | for field in self._meta.fields: 418 | raw_value = field.get_raw_value(self) 419 | setattr(self, field.name, field.clean(raw_value, self)) 420 | 421 | def full_clean(self): 422 | """ 423 | Calls clean_fields() and clean() 424 | """ 425 | self.clean_fields() 426 | self.clean() 427 | 428 | @contextmanager 429 | def lock(self): 430 | """ 431 | Acquires a lock for this object. 432 | """ 433 | with self._meta.workspace.lock(self.get_id()): 434 | yield 435 | 436 | @classmethod 437 | @concrete 438 | def get(cls, id, treeish=None): 439 | """ 440 | Gets the object associated with the given id 441 | """ 442 | workspace = cls._meta.workspace 443 | path = cls._meta.get_data_path(id) 444 | 445 | if treeish: 446 | tree = utils.treeish_to_tree(workspace.repo, treeish) 447 | else: 448 | tree = workspace.index 449 | 450 | msg = "{} with id {}{} does not exist." 451 | name = cls._meta.model_name 452 | revname = treeish and '@{}'.format(treeish) or '' 453 | msg = msg.format(name, id, revname) 454 | 455 | if not tree: 456 | raise exceptions.DoesNotExist(msg) 457 | 458 | try: 459 | blob = tree[path].oid 460 | except KeyError: 461 | raise exceptions.DoesNotExist(msg) 462 | data = workspace.repo[blob].data 463 | 464 | return cls._meta.serializer.deserialize(workspace, data, blob) 465 | 466 | @classmethod 467 | @concrete 468 | def all(cls): 469 | """ 470 | Returns a generator for all instances of this model. 471 | """ 472 | pattern = cls._meta.get_data_path('*') 473 | workspace = cls._meta.workspace 474 | repo = workspace.repo 475 | 476 | def all(): 477 | for path in utils.path.glob(repo, workspace.index, pattern): 478 | blob = workspace.index[path].oid 479 | data = workspace.repo[blob].data 480 | yield cls._meta.serializer.deserialize(workspace, data, blob) 481 | 482 | return ModelSet(all()) 483 | 484 | @classmethod 485 | @concrete 486 | def delete(cls, id, commit=False, **commit_info): 487 | """ 488 | Removes an object from its parent tree. This is a recursive operation, 489 | so if the stored object contains other objects within its directory, 490 | they will be removed as well. This is not the default case, but may be 491 | if the model employs a custom method for generating its data path. 492 | """ 493 | cls.get(id) 494 | path = os.path.dirname(cls._meta.get_data_path(id)) 495 | cls._meta.workspace.remove(path) 496 | 497 | if commit: 498 | return cls._meta.workspace.commit(**commit_info) 499 | 500 | 501 | class ModelSet(object): 502 | """ 503 | A read-only container type initailized with a generator 504 | """ 505 | def __init__(self, gen): 506 | self._gen = gen 507 | 508 | def __iter__(self): 509 | return self._gen 510 | 511 | def __getitem__(self, key): 512 | try: 513 | return next(o for i, o in enumerate(self._gen) if i == key) 514 | except StopIteration: 515 | raise IndexError("Index out of range") 516 | 517 | def __next__(self): 518 | return next(self._gen) 519 | 520 | def __contains__(self, item): 521 | return next(o for o in self._gen if o == item) 522 | -------------------------------------------------------------------------------- /gitmodel/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | ABORT = 1 2 | SET_EMPTY = 2 3 | IGNORE = 3 4 | -------------------------------------------------------------------------------- /gitmodel/serializers/json.py: -------------------------------------------------------------------------------- 1 | """ 2 | A JSON GitModel Serializer, which serves as the default serializer for 3 | GitModel objects. 4 | """ 5 | 6 | import datetime 7 | import decimal 8 | from StringIO import StringIO 9 | 10 | from gitmodel.utils import json 11 | from gitmodel.serializers import python 12 | 13 | 14 | def serialize(obj, fields=None, stream=None, **options): 15 | """ 16 | Serialize a GitModel object to JSON. 17 | 18 | fields: When None, serilizes all fields. Otherwise, only the given fields 19 | will be returned in the serialized output. 20 | 21 | stream: An optional file-like object that is passed to json.dump(). If not 22 | supplied, the entire JSON string will be returened. Otherwies, the 23 | stream object itself will be returned. 24 | 25 | options: Addition options to pass to json.dump() 26 | """ 27 | return_string = False 28 | if not stream: 29 | return_string = True 30 | stream = StringIO() 31 | 32 | pyobj = python.serialize(obj, fields) 33 | 34 | json.dump(pyobj, stream, cls=GitModelJSONEncoder, **options) 35 | if return_string: 36 | return stream.getvalue() 37 | return stream 38 | 39 | 40 | def deserialize(workspace, data, oid, **options): 41 | """ 42 | Load a JSON object string as a GitModel instance. 43 | 44 | model: the model class representing the data 45 | 46 | data: a valid JSON string 47 | 48 | options: additional options to pass to json.loads() 49 | """ 50 | data = json.loads(data, **options) 51 | return python.deserialize(workspace, data, oid) 52 | 53 | 54 | class GitModelJSONEncoder(json.JSONEncoder): 55 | """ 56 | JSONEncoder subclass that knows how to encode date/time and decimal types. 57 | """ 58 | def default(self, o): 59 | if isinstance(o, datetime.datetime): 60 | return o.isoformat() 61 | elif isinstance(o, datetime.date): 62 | return o.isoformat().split('T')[0] 63 | elif isinstance(o, datetime.time): 64 | return o.isoformat() 65 | elif isinstance(o, decimal.Decimal): 66 | return float(o) 67 | else: 68 | return super(GitModelJSONEncoder, self).default(o) 69 | -------------------------------------------------------------------------------- /gitmodel/serializers/python.py: -------------------------------------------------------------------------------- 1 | """ 2 | A Python GitModel Serializer. Handles serialization of GitModel objects to/from 3 | native python dictionaries. 4 | """ 5 | try: 6 | from collections import OrderedDict 7 | except ImportError: 8 | OrderedDict = dict 9 | 10 | from gitmodel.exceptions import ValidationError, ModelNotFound 11 | from gitmodel.serializers import ABORT, SET_EMPTY, IGNORE 12 | 13 | 14 | def serialize(obj, fields=None, invalid=ABORT): 15 | """ 16 | Serialize a GitModel object to JSON. 17 | 18 | fields: When None, serilizes all fields. Otherwise, only the given fields 19 | will be returned in the serialized output. 20 | 21 | invalid: If a field cannot be coerced into its respective data type, a 22 | ValidationError will be raised. When invalid is ABORT, this 23 | exception is re-raised. SET_EMPTY causes the value to be set to an 24 | empty value. IGNORE simply uses the current value. Note that 25 | serialization may still fail with IGNORE if a value is not 26 | serializable. 27 | """ 28 | pyobj = OrderedDict({ 29 | 'model': obj._meta.model_name, 30 | 'fields': {} 31 | }) 32 | for field in obj._meta.fields: 33 | if fields is None or field.name in fields: 34 | if field.serializable: 35 | try: 36 | value = field.serialize(obj) 37 | except ValidationError: 38 | if invalid == SET_EMPTY: 39 | value = field.empty_value 40 | elif invalid == IGNORE: 41 | value = getattr(obj, field.name) 42 | else: 43 | raise 44 | pyobj['fields'][field.name] = value 45 | 46 | return pyobj 47 | 48 | 49 | def deserialize(workspace, data, oid, invalid=IGNORE): 50 | """ 51 | Load a python dict as a GitModel instance. 52 | 53 | model: the model class representing the data 54 | 55 | data: a valid JSON string 56 | 57 | invalid: If a field cannot be coerced into its respective data type, a 58 | ``ValidationError`` will be raised. When ``invalid`` is ``ABORT``, 59 | this exception is re-raised. ``SET_EMPTY`` causes the value to be 60 | set to an empty value. ``IGNORE`` simply uses the raw value. 61 | """ 62 | attrs = {'oid': oid} 63 | try: 64 | model = workspace.models[data['model']] 65 | except KeyError: 66 | raise ModelNotFound(data['model']) 67 | for field in model._meta.fields: 68 | value = data['fields'].get(field.name) 69 | # field.deserialize() calls field.to_python(). If a serialized value 70 | # cannot be coerced into the correct type for its field, just assign 71 | # the raw value. 72 | try: 73 | value = field.deserialize(data, value) 74 | except ValidationError: 75 | if invalid == SET_EMPTY: 76 | value = field.empty_value 77 | elif invalid == ABORT: 78 | raise 79 | attrs[field.name] = value 80 | return model(**attrs) 81 | -------------------------------------------------------------------------------- /gitmodel/test/__init__.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import inspect 3 | import tempfile 4 | import os 5 | import re 6 | import shutil 7 | import pygit2 8 | 9 | 10 | class GitModelTestCase(unittest.TestCase): 11 | """ Sets up a temporary git repository for each test """ 12 | 13 | def setUp(self): 14 | # For tests, it's easier to use global_config so that we don't 15 | # have to pass a config object around. 16 | from gitmodel.workspace import Workspace 17 | from gitmodel import exceptions 18 | from gitmodel import utils 19 | 20 | self.exceptions = exceptions 21 | self.utils = utils 22 | 23 | # Create temporary repo to work from 24 | self.repo_path = tempfile.mkdtemp(prefix='python-gitmodel-') 25 | pygit2.init_repository(self.repo_path, False) 26 | self.workspace = Workspace(self.repo_path) 27 | 28 | def tearDown(self): 29 | # clean up test repo 30 | shutil.rmtree(self.repo_path) 31 | 32 | 33 | def get_module_suite(mod): 34 | """ 35 | Test modules may provide a suite() function, otherwise all TestCase 36 | subclasses are gethered automatically into a TestSuite 37 | """ 38 | # modules may provide a suite() function, 39 | if hasattr(mod, 'suite'): 40 | return mod.suite() 41 | else: 42 | # gather all testcases in this module into a suite 43 | suite = unittest.TestSuite() 44 | for name in dir(mod): 45 | obj = getattr(mod, name) 46 | if inspect.isclass(obj) and issubclass(obj, unittest.TestCase): 47 | suite.addTest(unittest.makeSuite(obj)) 48 | # Set a name attribute so we can find it later 49 | if mod.__name__.endswith('tests'): 50 | name = mod.__name__.split('.')[-2] 51 | else: 52 | name = mod.__name__.split('.')[-1] 53 | name = re.sub(r'^test_', '', name) 54 | suite.name = name 55 | suite.module = mod 56 | return suite 57 | 58 | 59 | def get_all_suites(): 60 | """ Yields all testsuites """ 61 | # Tests can be one of: 62 | # - test/suitename/tests.py 63 | # - test/test_suitename.py 64 | test_dir = os.path.dirname(__file__) 65 | for f in os.listdir(test_dir): 66 | mod = None 67 | if os.path.exists(os.path.join(test_dir, f, 'tests.py')): 68 | p = __import__('gitmodel.test.{}'.format(f), globals(), locals(), 69 | ['tests'], -1) 70 | mod = p.tests 71 | elif re.match(r'^test_\w+.py$', f): 72 | modname = f.replace('.py', '') 73 | p = __import__('gitmodel.test', globals(), locals(), [modname], -1) 74 | mod = getattr(p, modname) 75 | if mod: 76 | suite = get_module_suite(mod) 77 | yield suite 78 | 79 | 80 | def default_suite(): 81 | """ Sets up the default test suite """ 82 | suite = unittest.TestSuite() 83 | for other_suite in get_all_suites(): 84 | suite.addTest(other_suite) 85 | return suite 86 | 87 | 88 | class TestLoader(unittest.TestLoader): 89 | """ Allows tests to be referenced by name """ 90 | def loadTestsFromName(self, name, module=None): 91 | if name == 'suite': 92 | return default_suite() 93 | 94 | testcase = None 95 | if '.' in name: 96 | name, testcase = name.split('.', 1) 97 | 98 | for suite in get_all_suites(): 99 | if suite.name == name: 100 | if testcase is None: 101 | return suite 102 | return super(TestLoader, self).loadTestsFromName(testcase, 103 | suite.module) 104 | 105 | raise LookupError('could not find test case for "{}"'.format(name)) 106 | 107 | 108 | def main(): 109 | """ Runs the default test suite as a command line application. """ 110 | unittest.main(__name__, testLoader=TestLoader(), defaultTest='suite') 111 | -------------------------------------------------------------------------------- /gitmodel/test/fields/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bendavis78/python-gitmodel/730e62104d900d6c3450db5d4c2fa6dcfa0da03a/gitmodel/test/fields/__init__.py -------------------------------------------------------------------------------- /gitmodel/test/fields/git-logo-2color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bendavis78/python-gitmodel/730e62104d900d6c3450db5d4c2fa6dcfa0da03a/gitmodel/test/fields/git-logo-2color.png -------------------------------------------------------------------------------- /gitmodel/test/fields/models.py: -------------------------------------------------------------------------------- 1 | import pygit2 2 | 3 | from gitmodel import fields 4 | from gitmodel import models 5 | 6 | 7 | class Person(models.GitModel): 8 | slug = fields.SlugField() 9 | first_name = fields.CharField() 10 | last_name = fields.CharField() 11 | email = fields.EmailField() 12 | age = fields.IntegerField(required=False) 13 | account_balance = fields.DecimalField(required=False) 14 | birth_date = fields.DateField(required=False) 15 | active = fields.BooleanField(required=False) 16 | tax_rate = fields.FloatField(required=False) 17 | wake_up_call = fields.TimeField(required=False) 18 | date_joined = fields.DateTimeField(required=False) 19 | 20 | 21 | class Author(models.GitModel): 22 | first_name = fields.CharField() 23 | last_name = fields.CharField() 24 | email = fields.EmailField() 25 | language = fields.CharField(default='en-US') 26 | url = fields.URLField(schemes=('http', 'https'), required=False) 27 | 28 | 29 | class Post(models.GitModel): 30 | author = fields.RelatedField(Author) 31 | slug = fields.SlugField(id=True) 32 | title = fields.CharField() 33 | body = fields.CharField() 34 | image = fields.BlobField(required=False) 35 | metadata = fields.JSONField(required=False) 36 | 37 | 38 | class User(Person): 39 | password = fields.CharField() 40 | last_login = fields.DateTimeField(required=False) 41 | last_read = fields.RelatedField(Post, required=False) 42 | 43 | 44 | class GitObjectTestModel(models.GitModel): 45 | blob = fields.GitObjectField() 46 | commit = fields.GitObjectField(type=pygit2.Commit) 47 | tree = fields.GitObjectField() 48 | -------------------------------------------------------------------------------- /gitmodel/test/fields/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pygit2 4 | 5 | from gitmodel.test import GitModelTestCase 6 | 7 | 8 | class TestInstancesMixin(object): 9 | def setUp(self): 10 | super(TestInstancesMixin, self).setUp() 11 | 12 | from gitmodel.test.fields import models 13 | self.models = self.workspace.import_models(models) 14 | 15 | self.person = self.models.Person( 16 | slug='john-doe', 17 | first_name='John', 18 | last_name='Doe', 19 | email='jdoe@example.com', 20 | ) 21 | 22 | self.author = self.models.Author( 23 | email='jdoe@example.com', 24 | first_name='John', 25 | last_name='Doe', 26 | ) 27 | 28 | self.post = self.models.Post( 29 | slug='test-post', 30 | title='Test Post', 31 | body='Lorem ipsum dolor sit amet', 32 | ) 33 | 34 | 35 | class FieldValidationTest(TestInstancesMixin, GitModelTestCase): 36 | def test_validate_not_empty(self): 37 | # empty string on required field should trigger validationerror 38 | self.person.last_name = '' 39 | with self.assertRaises(self.exceptions.ValidationError): 40 | self.person.save() 41 | 42 | # None on required field should trigger validationerror 43 | self.person.last_name = None 44 | with self.assertRaises(self.exceptions.ValidationError): 45 | self.person.save() 46 | 47 | def test_validate_email(self): 48 | self.person.email = 'foo_at_example.com' 49 | with self.assertRaises(self.exceptions.ValidationError): 50 | self.person.save() 51 | 52 | def test_validate_slug(self): 53 | self.person.slug = 'Foo Bar' 54 | with self.assertRaises(self.exceptions.ValidationError): 55 | self.person.save() 56 | 57 | def test_validate_integer(self): 58 | self.person.age = 20.5 59 | with self.assertRaises(self.exceptions.ValidationError): 60 | self.person.save() 61 | self.person.age = 'twenty-one' 62 | with self.assertRaises(self.exceptions.ValidationError): 63 | self.person.save() 64 | 65 | def test_validate_float(self): 66 | self.person.tax_rate = '5%' 67 | with self.assertRaises(self.exceptions.ValidationError): 68 | self.person.save() 69 | self.person.tax_rate = '1.2.3' 70 | with self.assertRaises(self.exceptions.ValidationError): 71 | self.person.save() 72 | 73 | def test_validate_decimal(self): 74 | self.person.account_balance = 'one.two' 75 | with self.assertRaises(self.exceptions.ValidationError): 76 | self.person.save() 77 | self.person.account_balance = '1.2.3' 78 | with self.assertRaises(self.exceptions.ValidationError): 79 | self.person.save() 80 | 81 | def test_validate_date(self): 82 | # valid iso-8601 date 83 | self.person.birth_date = '1978-12-07' 84 | self.person.save() 85 | # not a valid iso-8601 date 86 | self.person.birth_date = '12/7/1978' 87 | with self.assertRaises(self.exceptions.ValidationError): 88 | self.person.save() 89 | 90 | def test_validate_datetime(self): 91 | # not a valid iso-8601 datetime 92 | self.person.date_joined = '12/8/2012 4:53pm' 93 | with self.assertRaises(self.exceptions.ValidationError): 94 | self.person.save() 95 | 96 | def test_validate_time(self): 97 | self.person.wake_up_call = '9am' 98 | with self.assertRaises(self.exceptions.ValidationError): 99 | self.person.save() 100 | self.person.wake_up_call = '2012-08-10 09:00' 101 | with self.assertRaises(self.exceptions.ValidationError): 102 | self.person.save() 103 | 104 | 105 | class FieldTypeCheckingTest(TestInstancesMixin, GitModelTestCase): 106 | 107 | def assertTypesMatch(self, field, test_values, type): 108 | for value, eq_value in test_values.iteritems(): 109 | setattr(self.person, field, value) 110 | self.person.save() 111 | person = self.models.Person.get(self.person.id) 112 | self.assertIsInstance(getattr(person, field), type) 113 | self.assertEqual(getattr(person, field), eq_value) 114 | 115 | def test_char(self): 116 | from datetime import datetime 117 | test_values = { 118 | 'John': 'John', 119 | .007: '0.007', 120 | datetime(2012, 12, 12): '2012-12-12 00:00:00' 121 | } 122 | self.assertTypesMatch('first_name', test_values, basestring) 123 | 124 | def test_integer(self): 125 | test_values = {33: 33, '33': 33} 126 | self.assertTypesMatch('age', test_values, int) 127 | 128 | def test_float(self): 129 | test_values = {.825: .825, '0.825': .825} 130 | self.assertTypesMatch('tax_rate', test_values, float) 131 | 132 | def test_decimal(self): 133 | from decimal import Decimal 134 | test_values = { 135 | '1.23': Decimal('1.23'), 136 | '12.300': Decimal('12.3'), 137 | 1: Decimal('1.0') 138 | } 139 | self.assertTypesMatch('account_balance', test_values, Decimal) 140 | 141 | def test_boolean(self): 142 | test_values = { 143 | True: True, 144 | False: False, 145 | 1: True, 146 | 0: False, 147 | None: False 148 | } 149 | self.assertTypesMatch('active', test_values, bool) 150 | 151 | def test_date(self): 152 | from datetime import date 153 | test_values = { 154 | '1978-12-7': date(1978, 12, 7), 155 | '1850-05-05': date(1850, 5, 5), 156 | } 157 | self.assertTypesMatch('birth_date', test_values, date) 158 | 159 | def test_datetime(self): 160 | from datetime import datetime 161 | from dateutil import tz 162 | utc = tz.tzutc() 163 | utc_offset = tz.tzoffset(None, -1 * 4 * 60 * 60) 164 | test_values = { 165 | '2012-05-30 14:32': datetime(2012, 5, 30, 14, 32), 166 | '1820-8-13 9:23:48Z': datetime(1820, 8, 13, 9, 23, 48, 0, utc), 167 | '2001-9-11 8:46:00-0400': datetime(2001, 9, 11, 8, 46, 0, 0, 168 | utc_offset), 169 | '2012-05-05 14:32:02.012345': datetime(2012, 5, 5, 14, 32, 2, 170 | 12345), 171 | } 172 | self.assertTypesMatch('date_joined', test_values, datetime) 173 | # test a normal date 174 | self.person.date_joined = '2012-01-01' 175 | self.person.save() 176 | person = self.models.Person.get(self.person.id) 177 | self.assertEqual(type(person.date_joined), datetime) 178 | self.assertEqual(person.date_joined, datetime(2012, 1, 1, 0, 0)) 179 | 180 | def test_time(self): 181 | from datetime import time 182 | from dateutil import tz 183 | utc = tz.tzutc() 184 | utc_offset = tz.tzoffset(None, -1 * 4 * 60 * 60) 185 | test_values = { 186 | '14:32': time(14, 32), 187 | '9:23:48Z': time(9, 23, 48, 0, utc), 188 | '8:46:00-0400': time(8, 46, 0, 0, utc_offset) 189 | } 190 | self.assertTypesMatch('wake_up_call', test_values, time) 191 | 192 | 193 | class RelatedFieldTest(TestInstancesMixin, GitModelTestCase): 194 | def test_related(self): 195 | self.author.save() 196 | self.post.author = self.author 197 | self.post.save() 198 | post_id = self.post.get_id() 199 | post = self.models.Post.get(post_id) 200 | self.assertTrue(post.author.get_id() == self.author.get_id()) 201 | 202 | 203 | class BlobFieldTest(TestInstancesMixin, GitModelTestCase): 204 | def test_blob_field(self): 205 | fd = open(os.path.join(os.path.dirname(__file__), 206 | 'git-logo-2color.png')) 207 | self.author.save() 208 | self.post.author = self.author 209 | self.post.image = fd 210 | self.post.save() 211 | 212 | #make sure stored file and original file are identical 213 | post = self.models.Post.get(self.post.get_id()) 214 | saved_content = post.image.read() 215 | fd.seek(0) 216 | control = fd.read() 217 | self.assertEqual(saved_content, control, 218 | "Saved blob does not match file") 219 | 220 | 221 | class InheritedFieldTest(TestInstancesMixin, GitModelTestCase): 222 | def test_inherited_local_fields(self): 223 | user = self.models.User( 224 | slug='john-doe', 225 | first_name='John', 226 | last_name='Doe', 227 | email='jdoe@example.com', 228 | password='secret' 229 | ) 230 | user.save() 231 | # get user 232 | user_retreived = self.models.User.get(user.id) 233 | self.assertEqual(user_retreived.password, 'secret') 234 | 235 | def test_inherited_related_fields(self): 236 | self.author.save() 237 | self.post.author = self.author 238 | self.post.save() 239 | user = self.models.User( 240 | slug='john-doe', 241 | first_name='John', 242 | last_name='Doe', 243 | email='jdoe@example.com', 244 | password='secret', 245 | last_read=self.post 246 | ) 247 | user.save() 248 | # get user 249 | user_retreived = self.models.User.get(user.id) 250 | self.assertEqual(user_retreived.last_read.get_id(), self.post.get_id()) 251 | 252 | 253 | class JSONFieldTest(TestInstancesMixin, GitModelTestCase): 254 | def test_json_field(self): 255 | metadata = { 256 | 'foo': 'bar', 257 | 'baz': 'qux' 258 | } 259 | self.author.save() 260 | self.post.author = self.author 261 | self.post.metadata = metadata 262 | self.post.save() 263 | post = self.models.Post.get(self.post.slug) 264 | self.assertIsInstance(post.metadata, dict) 265 | self.assertDictEqual(post.metadata, metadata) 266 | 267 | 268 | class GitObjectFieldTest(TestInstancesMixin, GitModelTestCase): 269 | def test_gitobject_field(self): 270 | repo = self.workspace.repo 271 | test_commit = self.person.save(commit=True, message='Test Commit') 272 | test_blob = repo[self.workspace.index[self.person.get_data_path()].oid] 273 | test_tree = repo[test_commit].tree 274 | 275 | obj = self.models.GitObjectTestModel( 276 | blob=test_blob.oid, 277 | commit=test_commit, 278 | tree=test_tree.oid 279 | ) 280 | obj.save() 281 | 282 | self.assertIsInstance(obj.commit, pygit2.Commit) 283 | self.assertEqual(obj.commit.oid, repo[test_commit].oid) 284 | self.assertIsInstance(obj.blob, pygit2.Blob) 285 | self.assertEqual(obj.blob.oid, test_blob.oid) 286 | self.assertIsInstance(obj.tree, pygit2.Tree) 287 | self.assertEqual(obj.tree.oid, test_tree.oid) 288 | 289 | err = '"commit" must be a valid git OID' 290 | with self.assertRaisesRegexp(self.exceptions.ValidationError, err): 291 | obj.commit = 'foo' 292 | obj.save() 293 | 294 | err = '"commit" must point to a Commit' 295 | with self.assertRaisesRegexp(self.exceptions.ValidationError, err): 296 | obj.commit = test_tree.oid 297 | obj.save() 298 | 299 | 300 | class EmailFieldTest(TestInstancesMixin, GitModelTestCase): 301 | def test_email_field(self): 302 | invalid = '"email" must be a valid e-mail address' 303 | 304 | with self.assertRaisesRegexp(self.exceptions.ValidationError, invalid): 305 | self.author.email = 'jdoe[at]example.com' 306 | self.author.save() 307 | 308 | self.author.email = 'jdoe@example.com' 309 | self.author.save() 310 | id = self.author.id 311 | 312 | author = self.models.Author.get(id) 313 | self.assertEqual(author.email, 'jdoe@example.com') 314 | 315 | 316 | class URLFieldTest(TestInstancesMixin, GitModelTestCase): 317 | def test_url_field(self): 318 | invalid = '"url" must be a valid URL' 319 | invalid_scheme = '"url" scheme must be one of http, https' 320 | 321 | with self.assertRaisesRegexp(self.exceptions.ValidationError, invalid): 322 | self.author.url = 'http//example.com/foo' 323 | self.author.save() 324 | 325 | with self.assertRaisesRegexp(self.exceptions.ValidationError, 326 | invalid_scheme): 327 | self.author.url = 'ftp://example.com/foo' 328 | self.author.save() 329 | 330 | self.author.url = 'http://example.com/foo' 331 | self.author.save() 332 | id = self.author.id 333 | 334 | author = self.models.Author.get(id) 335 | self.assertEqual(author.url, 'http://example.com/foo') 336 | -------------------------------------------------------------------------------- /gitmodel/test/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bendavis78/python-gitmodel/730e62104d900d6c3450db5d4c2fa6dcfa0da03a/gitmodel/test/model/__init__.py -------------------------------------------------------------------------------- /gitmodel/test/model/diff_branch.diff: -------------------------------------------------------------------------------- 1 | diff --git a/{0} b/{0} 2 | index {1}..{2} 100644 3 | --- a/{0} 4 | +++ b/{0} 5 | @@ -1 +1 @@ 6 | -{{"fields": {{"first_name": "John", "last_name": "Doe", "id": "{3}", "language": "en-US", "email": "jdoe@example.com"}}, "model": "Author"}} 7 | \ No newline at end of file 8 | +{{"fields": {{"first_name": "Jane", "last_name": "Doe", "id": "{3}", "language": "en-US", "email": "jdoe@example.com"}}, "model": "Author"}} 9 | \ No newline at end of file 10 | -------------------------------------------------------------------------------- /gitmodel/test/model/diff_nobranch.diff: -------------------------------------------------------------------------------- 1 | diff --git a/{0} b/{0} 2 | new file mode 100644 3 | index 0000000..{1} 4 | --- /dev/null 5 | +++ b/{0} 6 | @@ -0,0 +1 @@ 7 | +{{"fields": {{"first_name": "John", "last_name": "Doe", "id": "{2}", "language": "en-US", "email": "jdoe@example.com"}}, "model": "Author"}} 8 | \ No newline at end of file 9 | -------------------------------------------------------------------------------- /gitmodel/test/model/models.py: -------------------------------------------------------------------------------- 1 | from gitmodel import fields 2 | from gitmodel.models import GitModel 3 | 4 | 5 | class Author(GitModel): 6 | first_name = fields.CharField() 7 | last_name = fields.CharField() 8 | email = fields.CharField() 9 | language = fields.CharField(default='en-US') 10 | 11 | 12 | class Post(GitModel): 13 | slug = fields.SlugField(id=True) 14 | title = fields.CharField() 15 | body = fields.CharField() 16 | image = fields.BlobField(required=False) 17 | 18 | 19 | class Person(GitModel): 20 | first_name = fields.CharField() 21 | last_name = fields.CharField() 22 | email = fields.EmailField() 23 | 24 | class Meta: 25 | data_filename = 'person_data.json' 26 | 27 | 28 | class User(Person): 29 | password = fields.CharField() 30 | date_joined = fields.DateField() 31 | 32 | 33 | def get_path_custom(opts, object_id): 34 | import os 35 | # kinda silly, but good for testing that the override works 36 | model_name = opts.model_name.lower() 37 | model_name = model_name.replace('alternate', '-alt') 38 | return os.path.join(model_name, unicode(object_id), 'data.json') 39 | 40 | 41 | class PostAlternate(GitModel): 42 | slug = fields.SlugField() 43 | title = fields.CharField() 44 | 45 | class Meta: 46 | id_attr = 'slug' 47 | get_data_path = get_path_custom 48 | 49 | 50 | class PostAlternateSub(PostAlternate): 51 | foo = fields.CharField() 52 | 53 | class Meta(PostAlternate.Meta): 54 | id_attr = 'foo' 55 | 56 | 57 | class AbstractBase(GitModel): 58 | field_one = fields.CharField() 59 | field_two = fields.CharField() 60 | 61 | class Meta: 62 | abstract = True 63 | 64 | 65 | class Concrete(AbstractBase): 66 | field_three = fields.CharField() 67 | -------------------------------------------------------------------------------- /gitmodel/test/model/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from gitmodel.test import GitModelTestCase 4 | 5 | 6 | class TestInstancesMixin(object): 7 | def setUp(self): 8 | super(TestInstancesMixin, self).setUp() 9 | 10 | from gitmodel.test.model import models 11 | from gitmodel import exceptions 12 | from gitmodel import fields 13 | 14 | self.exceptions = exceptions 15 | self.fields = fields 16 | self.workspace.import_models(models) 17 | self.models = self.workspace.models 18 | 19 | self.author = self.models.Author( 20 | email='jdoe@example.com', 21 | first_name='John', 22 | last_name='Doe', 23 | ) 24 | 25 | self.post = self.models.Post( 26 | slug='test-post', 27 | title='Test Post', 28 | body='Lorem ipsum dolor sit amet', 29 | ) 30 | 31 | 32 | class GitModelBasicTest(TestInstancesMixin, GitModelTestCase): 33 | 34 | def test_type(self): 35 | author = self.models.Author() 36 | self.assertIsInstance(author, self.models.GitModel) 37 | 38 | def test_meta(self): 39 | self.assertIsNotNone(self.models.Author._meta) 40 | self.assertEqual(self.models.Person._meta.data_filename, 41 | "person_data.json") 42 | self.assertEqual(self.models.User._meta.data_filename, 43 | "person_data.json") 44 | 45 | def test_workspace_in_model_meta(self): 46 | from gitmodel.workspace import Workspace 47 | self.assertIsInstance(self.models.Author._meta.workspace, Workspace) 48 | 49 | def test_fields_added_to_meta(self): 50 | fields = [f.name for f in self.models.Author._meta.fields] 51 | self.assertEqual(fields, [ 52 | 'id', 53 | 'first_name', 54 | 'last_name', 55 | 'email', 56 | 'language' 57 | ]) 58 | 59 | def test_has_id_attr(self): 60 | self.assertIsNotNone(self.author._meta.id_attr) 61 | 62 | def test_id(self): 63 | self.assertTrue(hasattr(self.author, 'id')) 64 | 65 | def test_create_from_kwargs(self): 66 | self.assertEqual(self.author.first_name, 'John') 67 | 68 | def test_property_assignment(self): 69 | author = self.models.Author() 70 | author.first_name = 'John' 71 | self.assertEqual(author.first_name, 'John') 72 | 73 | def test_get_data_path(self): 74 | self.author.save() 75 | path = self.author.get_data_path() 76 | test_path = 'author/{}/data.json'.format(self.author.get_id()) 77 | self.assertEqual(path, test_path) 78 | 79 | def test_subclass_meta(self): 80 | obj = self.models.PostAlternateSub(foo='bar') 81 | path = obj.get_data_path() 82 | test_path = 'post-altsub/bar/data.json' 83 | self.assertEqual(path, test_path) 84 | 85 | def test_save_oid(self): 86 | self.assertIsNone(self.author.oid) 87 | self.author.save(commit=True) 88 | self.assertIsNotNone(self.author.oid) 89 | 90 | def test_get_oid(self): 91 | self.author.save(commit=True) 92 | test_oid = self.author.oid 93 | self.assertIsNotNone(test_oid) 94 | obj = self.workspace.index[self.author.get_data_path()] 95 | self.assertEqual(obj.oid, test_oid) 96 | 97 | def test_field_default(self): 98 | self.assertEqual(self.author.language, 'en-US') 99 | 100 | def test_save(self): 101 | # save without adding to index or commit 102 | self.author.save() 103 | 104 | # get json from the returned tree using pygit2 code 105 | entry = self.workspace.index[self.author.get_data_path()] 106 | blob = self.workspace.repo[entry.oid] 107 | 108 | # verify data 109 | data = json.loads(blob.data) 110 | self.assertItemsEqual(data, { 111 | 'model': 'Author', 112 | 'fields': { 113 | 'id': self.author.get_id(), 114 | 'first_name': 'John', 115 | 'last_name': 'Doe', 116 | 'email': 'jdoe@example.com', 117 | 'language': '', 118 | } 119 | }) 120 | 121 | def test_delete(self): 122 | self.author.save() 123 | id = self.author.get_id() 124 | self.post.save() 125 | post_id = self.post.get_id() 126 | get_author = self.models.Author.get(id) 127 | self.assertEqual(id, get_author.get_id()) 128 | self.models.Author.delete(id) 129 | with self.assertRaises(self.exceptions.DoesNotExist): 130 | self.models.Author.get(id) 131 | # make sure our index isn't borked 132 | post = self.models.Post.get(post_id) 133 | self.assertEqual(post.get_id(), post_id) 134 | 135 | def test_save_commit(self): 136 | commit_info = { 137 | 'author': ('John Doe', 'jdoe@example.com'), 138 | 'message': 'Testing save with commit' 139 | } 140 | commit_id = self.author.save(commit=True, **commit_info) 141 | commit = self.workspace.repo[commit_id] 142 | 143 | # verify commit 144 | self.assertEqual(commit.author.name, 'John Doe') 145 | self.assertEqual(commit.author.email, 'jdoe@example.com') 146 | self.assertEqual(commit.message, 'Testing save with commit') 147 | 148 | # get json from the returned tree using pygit2 code 149 | entry = commit.tree[self.author.get_data_path()] 150 | blob = self.workspace.repo[entry.oid] 151 | 152 | # verify data 153 | data = json.loads(blob.data) 154 | self.assertItemsEqual(data, { 155 | 'model': 'Author', 156 | 'fields': { 157 | 'id': self.author.get_id(), 158 | 'first_name': 'John', 159 | 'last_name': 'Doe', 160 | 'email': 'jdoe@example.com', 161 | 'language': '', 162 | } 163 | }) 164 | 165 | def test_diff_nobranch(self): 166 | # Tests a diff when a save is made with no previous commits 167 | self.maxDiff = None 168 | self.author.save() 169 | self.assertTrue(self.workspace.has_changes()) 170 | blob_hash = self.workspace.index[self.author.get_data_path()].hex[:7] 171 | diff = open(os.path.join(os.path.dirname(__file__), 172 | 'diff_nobranch.diff')).read() 173 | diff = diff.format(self.author.get_data_path(), blob_hash, 174 | self.author.id) 175 | self.assertMultiLineEqual(diff, self.workspace.diff().patch) 176 | 177 | def test_diff_branch(self): 178 | # Tests a diff when a save is made with previous commits 179 | self.maxDiff = None 180 | self.author.save(commit=True, message="Test first commit") 181 | blob_hash_1 = self.workspace.index[self.author.get_data_path()].hex[:7] 182 | self.author.first_name = 'Jane' 183 | self.author.save() 184 | blob_hash_2 = self.workspace.index[self.author.get_data_path()].hex[:7] 185 | diff = open(os.path.join(os.path.dirname(__file__), 186 | 'diff_branch.diff')).read() 187 | diff = diff.format(self.author.get_data_path(), blob_hash_1, 188 | blob_hash_2, self.author.id) 189 | self.assertMultiLineEqual(diff, self.workspace.diff().patch) 190 | 191 | def test_save_commit_history(self): 192 | # Test that commited models save correctly 193 | import pygit2 194 | commit1 = self.author.save(commit=True, message="Test first commit") 195 | self.author.first_name = 'Jane' 196 | commit2 = self.author.save(commit=True, message="Changed name to Jane") 197 | self.assertEqual(self.workspace.branch.commit.oid, commit2) 198 | self.assertEqual(self.workspace.repo[commit2].parents[0].oid, commit1) 199 | walktree = self.workspace.repo.walk(self.workspace.branch.oid, 200 | pygit2.GIT_SORT_TIME) 201 | commits = [c for c in walktree] 202 | self.assertEqual(commits[0].oid, commit2) 203 | self.assertEqual(commits[1].oid, commit1) 204 | 205 | def test_get_simple_object(self): 206 | self.author.save(commit=True) 207 | id = self.author.id 208 | author = self.models.Author.get(self.author.get_id()) 209 | self.assertEqual(author.id, id) 210 | self.assertEqual(author.first_name, 'John') 211 | self.assertEqual(author.last_name, 'Doe') 212 | self.assertEqual(author.email, 'jdoe@example.com') 213 | 214 | def test_save_custom_id(self): 215 | self.post.save(commit=True) 216 | post = self.models.Post.get('test-post') 217 | self.assertEqual(post.get_id(), 'test-post') 218 | self.assertEqual(post.slug, 'test-post') 219 | self.assertEqual(post.title, 'Test Post') 220 | 221 | def test_id_validator(self): 222 | # "/" and "\0" are both invalid characters 223 | self.author.id = 'foo/bar' 224 | with self.assertRaises(self.exceptions.ValidationError): 225 | self.author.save() 226 | 227 | self.author.id = 'foo\000bar' 228 | with self.assertRaises(self.exceptions.ValidationError): 229 | self.author.save() 230 | 231 | def test_require_fields(self): 232 | test_author = self.models.Author(first_name='Jane') 233 | with self.assertRaises(self.exceptions.ValidationError): 234 | test_author.save() 235 | 236 | def test_custom_id_attr(self): 237 | # id should resolve to the slug field, since slug is marked as id=True 238 | self.post.save() 239 | self.assertEqual(self.post.get_id(), self.post.slug) 240 | 241 | def test_overridden_id_field(self): 242 | # tests bug that occured when overriding the id field and not using 243 | # that field as the id_attr 244 | class Resource(self.models.GitModel): 245 | __workspace__ = self.workspace 246 | id = self.fields.UUIDField() 247 | path = self.fields.CharField() 248 | 249 | class Meta: 250 | id_attr = 'path' 251 | 252 | fields_named_id = (f for f in Resource._meta.fields if f.name == 'id') 253 | self.assertEqual(len(tuple(fields_named_id)), 1) 254 | 255 | def test_basic_inheritance(self): 256 | fields = [f.name for f in self.models.User._meta.fields] 257 | self.assertEqual(fields, [ 258 | 'id', 259 | 'first_name', 260 | 'last_name', 261 | 'email', 262 | 'password', 263 | 'date_joined', 264 | ]) 265 | 266 | def test_inherited_field_clash(self): 267 | with self.assertRaises(self.exceptions.FieldError): 268 | # first_name should clash with the parent models' first_name field 269 | class User(self.models.Person): 270 | first_name = self.fields.CharField() 271 | password = self.fields.CharField() 272 | date_joined = self.fields.DateField() 273 | 274 | def test_meta_overrides(self): 275 | self.assertEqual(self.models.PostAlternate._meta.id_attr, 'slug') 276 | 277 | def test_custom_path_override(self): 278 | post = self.models.PostAlternate(slug='foobar', title='Foobar') 279 | post.save() 280 | self.assertEqual(post.get_data_path(), 'post-alt/foobar/data.json') 281 | 282 | def test_commit_when_pending_changes(self): 283 | self.author.save() 284 | self.author.first_name = 'Jane' 285 | with self.assertRaises(self.exceptions.RepositoryError): 286 | self.author.save(commit=True) 287 | 288 | def test_multiple_saves_before_commit(self): 289 | self.author.save() 290 | author_id = self.author.get_id() 291 | self.post.save() 292 | post_id = self.post.get_id() 293 | self.assertEqual(author_id, self.models.Author.get(author_id).get_id()) 294 | self.assertEqual(post_id, self.models.Post.get(post_id).get_id()) 295 | 296 | def test_concrete(self): 297 | # import the unregistered model 298 | from gitmodel.test.model.models import Author 299 | 300 | self.author.save() 301 | id = self.author.get_id() 302 | 303 | err = ('Cannot call .*? because .*? has not been registered with a ' 304 | 'workspace') 305 | 306 | # try to init an unregistered model 307 | with self.assertRaisesRegexp(self.exceptions.GitModelError, err): 308 | Author(first_name='John', last_name='Doe') 309 | 310 | # try to use .get() on the unregistered model 311 | with self.assertRaisesRegexp(self.exceptions.GitModelError, err): 312 | Author.get(id) 313 | 314 | def test_abstract(self): 315 | # try to do stuff on an abstract model 316 | with self.assertRaises(TypeError): 317 | self.models.AbstractBase.get() 318 | 319 | with self.assertRaises(self.exceptions.GitModelError): 320 | self.models.AbstractBase.get('1') 321 | 322 | with self.assertRaises(self.exceptions.GitModelError): 323 | self.models.AbstractBase.all() 324 | 325 | concrete = self.models.Concrete(field_one='1', field_two='2', 326 | field_three='3') 327 | concrete.save() 328 | test_concrete = self.models.Concrete.get(id=concrete.id) 329 | self.assertEqual(test_concrete.field_one, '1') 330 | self.assertEqual(test_concrete.field_two, '2') 331 | self.assertEqual(test_concrete.field_three, '3') 332 | 333 | def test_all(self): 334 | author1 = self.author 335 | author2 = self.models.Author( 336 | first_name='Zeb', 337 | last_name='Doe', 338 | email='zdoe@example.com' 339 | ) 340 | author1.save() 341 | author2.save() 342 | authors = self.models.Author.all() 343 | self.assertTrue(hasattr(authors, '__iter__')) 344 | authors = list(authors) 345 | authors.sort(key=lambda a: a.first_name) 346 | self.assertEqual(authors[0].id, author1.id) 347 | self.assertEqual(authors[1].id, author2.id) 348 | 349 | def test_unique_id(self): 350 | self.post.save() 351 | p2 = self.models.Post( 352 | slug='test-post', 353 | title='A duplicate test post', 354 | body='Lorem ipsum dupor sit amet', 355 | ) 356 | err = 'A .*? instance already exists with id .*?' 357 | with self.assertRaisesRegexp(self.exceptions.IntegrityError, err): 358 | p2.save() 359 | 360 | def test_moved_path(self): 361 | self.post.save() 362 | old_path = self.post.get_data_path() 363 | self.post.slug = 'updated-post' 364 | self.post.save() 365 | self.assertNotEqual(old_path, self.post.get_data_path()) 366 | with self.assertRaises(KeyError): 367 | self.workspace.index[old_path] 368 | self.models.Post.get('updated-post') 369 | -------------------------------------------------------------------------------- /gitmodel/test/test_utils.py: -------------------------------------------------------------------------------- 1 | import pygit2 2 | from gitmodel.test import GitModelTestCase 3 | 4 | 5 | class GitModelUtilsTest(GitModelTestCase): 6 | def setUp(self): 7 | super(GitModelUtilsTest, self).setUp() 8 | self.repo = self.workspace.repo 9 | 10 | def _get_test_tree(self): 11 | repo = self.repo 12 | # builds the following tree: 13 | # 14 | # foo/ 15 | # bar/ 16 | # baz/ 17 | # test2.txt 18 | # test.txt 19 | # test3.txt 20 | test_txt = repo.create_blob("TEST") 21 | test2_txt = repo.create_blob("TEST 2") 22 | test3_text = repo.create_blob("TEST 3") 23 | baz_tb = repo.TreeBuilder() 24 | baz_tb.insert('test2.txt', test2_txt, pygit2.GIT_FILEMODE_BLOB) 25 | baz = baz_tb.write() 26 | bar_tb = repo.TreeBuilder() 27 | bar_tb.insert('test.txt', test_txt, pygit2.GIT_FILEMODE_BLOB) 28 | bar_tb.insert('test3.txt', test3_text, pygit2.GIT_FILEMODE_BLOB) 29 | bar_tb.insert('baz', baz, pygit2.GIT_FILEMODE_TREE) 30 | bar = bar_tb.write() 31 | foo_tb = repo.TreeBuilder() 32 | foo_tb.insert('bar', bar, pygit2.GIT_FILEMODE_TREE) 33 | foo = foo_tb.write() 34 | root_tb = repo.TreeBuilder() 35 | root_tb.insert('foo', foo, pygit2.GIT_FILEMODE_TREE) 36 | root = root_tb.write() 37 | return root 38 | 39 | def test_describe_tree(self): 40 | from gitmodel import utils 41 | root = self._get_test_tree() 42 | desc = utils.path.describe_tree(self.repo, root) 43 | test_desc = ('foo/\n' 44 | ' bar/\n' 45 | ' baz/\n' 46 | ' test2.txt\n' 47 | ' test.txt\n' 48 | ' test3.txt') 49 | self.assertMultiLineEqual(desc, test_desc) 50 | 51 | def test_make_signature(self): 52 | from gitmodel import utils 53 | from datetime import datetime 54 | from time import time 55 | from dateutil.tz import tzlocal 56 | 57 | # Get local offset 58 | timestamp = time() 59 | dt = datetime.fromtimestamp(timestamp) 60 | aware = datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, 61 | dt.second, dt.microsecond, tzinfo=tzlocal()) 62 | seconds = aware.utcoffset().days * 86400 63 | seconds += aware.utcoffset().seconds 64 | offset = seconds / 60 65 | 66 | test_sig = utils.make_signature('Tester Test', 'test@example.com', 67 | timestamp=timestamp) 68 | self.assertEqual(test_sig.name, 'Tester Test') 69 | self.assertEqual(test_sig.email, 'test@example.com') 70 | self.assertEqual(test_sig.offset, offset) 71 | self.assertAlmostEqual(test_sig.time, timestamp, -1) 72 | 73 | # since we defined passed timestamp earlier, test that timestamp is 74 | # automatically created 75 | test_sig = utils.make_signature('Tester Test', 'test@example.com') 76 | self.assertAlmostEqual(test_sig.time, timestamp, delta=10) 77 | 78 | def test_build_path_empty(self): 79 | # Test building a path from an empty tree 80 | from gitmodel import utils 81 | path = '/foo/bar/baz/' # path sep should be stripped 82 | # create dummy entry 83 | blob_oid = self.repo.create_blob("TEST CONTENT") 84 | entries = [('qux.txt', blob_oid, pygit2.GIT_FILEMODE_BLOB)] 85 | oid = utils.path.build_path(self.repo, path, entries) 86 | desc = utils.path.describe_tree(self.repo, oid) 87 | test_desc = 'foo/\n bar/\n baz/\n qux.txt' 88 | self.assertMultiLineEqual(desc, test_desc) 89 | 90 | def test_build_path_update(self): 91 | # Test building a path from an existing tree, updating the path 92 | from gitmodel import utils 93 | path = '/foo/bar/baz/' # path sep should be stripped 94 | # build initial tree 95 | blob_oid = self.repo.create_blob("TEST CONTENT") 96 | entries = [('qux.txt', blob_oid, pygit2.GIT_FILEMODE_BLOB)] 97 | tree1 = utils.path.build_path(self.repo, path, entries) 98 | 99 | # build the same path, but this time with a new blob 100 | blob_oid = self.repo.create_blob("UPDATED CONTENT") 101 | entries = [('qux.txt', blob_oid, pygit2.GIT_FILEMODE_BLOB)] 102 | tree2 = utils.path.build_path(self.repo, path, entries, tree1) 103 | 104 | entry = self.repo[tree2]['foo/bar/baz/qux.txt'] 105 | new_content = self.repo[entry.oid].data 106 | desc = utils.path.describe_tree(self.repo, tree2) 107 | test_desc = 'foo/\n bar/\n baz/\n qux.txt' 108 | self.assertEqual(new_content, 'UPDATED CONTENT') 109 | self.assertMultiLineEqual(desc, test_desc) 110 | 111 | def test_glob(self): 112 | from gitmodel import utils 113 | tree = self._get_test_tree() 114 | files = utils.path.glob(self.repo, tree, 'foo/*/*.txt') 115 | test = ['foo/bar/test.txt', 'foo/bar/test3.txt'] 116 | self.assertEqual(list(files), test) 117 | -------------------------------------------------------------------------------- /gitmodel/test/test_workspace.py: -------------------------------------------------------------------------------- 1 | from gitmodel.test import GitModelTestCase 2 | from gitmodel import exceptions 3 | 4 | 5 | class GitModelWorkspaceTest(GitModelTestCase): 6 | def setUp(self): 7 | super(GitModelWorkspaceTest, self).setUp() 8 | self.repo = self.workspace.repo 9 | 10 | def test_workspace_init(self): 11 | from gitmodel.conf import Config 12 | import pygit2 13 | self.assertIsInstance(self.workspace.config, Config) 14 | self.assertIsInstance(self.workspace.repo, pygit2.Repository) 15 | 16 | def test_base_gitmodel(self): 17 | from gitmodel.models import GitModel, DeclarativeMetaclass 18 | self.assertIsInstance(GitModel, DeclarativeMetaclass) 19 | self.assertIsInstance(self.workspace.models.GitModel, 20 | DeclarativeMetaclass) 21 | 22 | def test_register_model(self): 23 | from gitmodel.models import GitModel, DeclarativeMetaclass 24 | from gitmodel import fields 25 | 26 | class TestModel(GitModel): 27 | foo = fields.CharField() 28 | bar = fields.CharField() 29 | 30 | self.workspace.register_model(TestModel) 31 | self.assertIsNotNone(self.workspace.models.get('TestModel')) 32 | test_model = self.workspace.models.TestModel() 33 | self.assertIsInstance(test_model, self.workspace.models.TestModel) 34 | self.assertIsInstance(type(test_model), DeclarativeMetaclass) 35 | self.assertEqual(test_model._meta.workspace, self.workspace) 36 | 37 | def test_init_existing_branch(self): 38 | from gitmodel.workspace import Workspace 39 | # Test init of workspace with existing branch 40 | # create a commit on existing workspace 41 | self.workspace.add_blob('test.txt', 'Test') 42 | self.workspace.commit('initial commit') 43 | new_workspace = Workspace(self.workspace.repo.path) 44 | self.assertEqual(new_workspace.branch.ref.name, 'refs/heads/master') 45 | self.assertEqual(new_workspace.branch.commit.message, 'initial commit') 46 | 47 | def test_getitem(self): 48 | self.workspace.add_blob('test.txt', 'Test') 49 | entry = self.workspace.index['test.txt'] 50 | self.assertEqual(self.repo[entry.oid].data, 'Test') 51 | 52 | def test_branch_property(self): 53 | self.assertIsNone(self.workspace.branch) 54 | self.workspace.add_blob('test.txt', 'Test') 55 | self.workspace.commit('initial commit') 56 | self.assertIsNotNone(self.workspace.branch) 57 | self.assertEqual(self.workspace.branch.ref.name, 'refs/heads/master') 58 | self.assertEqual(self.workspace.branch.commit.message, 59 | 'initial commit') 60 | 61 | def test_set_branch(self): 62 | # create intial master branch 63 | self.workspace.add_blob('test.txt', 'Test') 64 | self.workspace.commit('initial commit') 65 | # create a new branch 66 | self.workspace.create_branch('testbranch') 67 | # set_branch will automatically update the index 68 | self.workspace.set_branch('testbranch') 69 | self.workspace.add_blob('test.txt', 'Test 2') 70 | self.workspace.commit('test branch commit') 71 | 72 | entry = self.workspace.index['test.txt'] 73 | test_content = self.repo[entry.oid].data 74 | self.assertEqual(test_content, 'Test 2') 75 | 76 | self.workspace.set_branch('master') 77 | entry = self.workspace.index['test.txt'] 78 | test_content = self.repo[entry.oid].data 79 | self.assertEqual(test_content, 'Test') 80 | 81 | def test_set_nonexistant_branch(self): 82 | with self.assertRaises(KeyError): 83 | self.workspace.set_branch('foobar') 84 | 85 | def test_update_index_with_pending_changes(self): 86 | self.workspace.add_blob('test.txt', 'Test') 87 | self.workspace.commit('initial commit') 88 | with self.assertRaisesRegexp(exceptions.RepositoryError, r'pending'): 89 | self.workspace.add_blob('test.txt', 'Test 2') 90 | self.workspace.create_branch('testbranch') 91 | self.workspace.set_branch('testbranch') 92 | 93 | def test_add_blob(self): 94 | self.workspace.add_blob('test.txt', 'Test') 95 | entry = self.workspace.index['test.txt'] 96 | self.assertEqual(self.repo[entry.oid].data, 'Test') 97 | 98 | def test_remove(self): 99 | self.workspace.add_blob('test.txt', 'Test') 100 | entry = self.workspace.index['test.txt'] 101 | self.assertEqual(self.repo[entry.oid].data, 'Test') 102 | self.workspace.remove('test.txt') 103 | with self.assertRaises(KeyError): 104 | self.workspace.index['test.txt'] 105 | 106 | def test_commit_on_success(self): 107 | with self.workspace.commit_on_success('Test commit'): 108 | self.workspace.add_blob('test.txt', 'Test') 109 | self.assertEqual(self.workspace.branch.commit.message, 'Test commit') 110 | 111 | def test_commit_on_success_with_error(self): 112 | # make an exception we can catch 113 | class TestException(Exception): 114 | pass 115 | try: 116 | with self.workspace.commit_on_success('Test commit'): 117 | self.workspace.add_blob('test.txt', 'Test') 118 | raise TestException('dummy error') 119 | except TestException: 120 | pass 121 | # since commit should have failed, current branch should be nonexistent 122 | self.assertEqual(self.workspace.branch, None) 123 | 124 | def test_commit_on_success_with_pending_changes(self): 125 | self.workspace.add_blob('foo.txt', 'Foobar') 126 | with self.assertRaisesRegexp(exceptions.RepositoryError, r'pending'): 127 | with self.workspace.commit_on_success('Test commit'): 128 | self.workspace.add_blob('test.txt', 'Test') 129 | self.assertEqual(self.workspace.branch, None) 130 | 131 | def test_has_changes(self): 132 | self.workspace.add_blob('foo.txt', 'Foobar') 133 | self.assertTrue(self.workspace.has_changes()) 134 | -------------------------------------------------------------------------------- /gitmodel/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | from dateutil.tz import tzlocal 4 | from datetime import datetime 5 | from time import time 6 | 7 | import pygit2 8 | 9 | from . import path 10 | 11 | # We disable c_make_encoder for python between versions 2.7 and 2.7.3, so that 12 | # we can use collections.OrderedDict when encoding. 13 | if 0x20700f0 <= sys.hexversion < 0x20703f0: 14 | json.encoder.c_make_encoder = None 15 | 16 | __all__ = ['json', 'make_signature', 'path'] 17 | 18 | 19 | def make_signature(name, email, timestamp=None, offset=None, 20 | default_offset=None): 21 | """ 22 | Creates a pygit2.Signature while making time and offset optional. By 23 | default, uses current time, and local offset as determined by 24 | ``dateutil.tz.tzlocal()`` 25 | """ 26 | if timestamp is None: 27 | timestamp = time() 28 | 29 | if offset is None and default_offset is None: 30 | # Get local offset 31 | dt = datetime.fromtimestamp(timestamp) 32 | aware = datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, 33 | dt.second, dt.microsecond, tzinfo=tzlocal()) 34 | seconds = aware.utcoffset().days * 86400 35 | seconds += aware.utcoffset().seconds 36 | offset = seconds / 60 37 | elif offset is None: 38 | offset = default_offset 39 | 40 | return pygit2.Signature(name, email, timestamp, offset) 41 | 42 | 43 | def treeish_to_tree(repo, obj): 44 | try: 45 | obj = repo.revparse_single(obj) 46 | except: 47 | pass 48 | 49 | if isinstance(obj, pygit2.Commit): 50 | return obj.tree 51 | elif isinstance(obj, pygit2.Reference): 52 | oid = obj.resolve().target 53 | return repo[oid] 54 | -------------------------------------------------------------------------------- /gitmodel/utils/dict.py: -------------------------------------------------------------------------------- 1 | def dict_strip_unicode_keys(uni_dict): 2 | """ 3 | Converts a dict of unicode keys into a dict of ascii keys. 4 | 5 | Useful for converting a dict to a kwarg-able format. 6 | """ 7 | data = {} 8 | 9 | for key, value in uni_dict.items(): 10 | data[str(key)] = value 11 | 12 | return data 13 | -------------------------------------------------------------------------------- /gitmodel/utils/encoding.py: -------------------------------------------------------------------------------- 1 | """ 2 | Taken from Django's utls.encoding and modified 3 | """ 4 | import datetime 5 | import types 6 | from decimal import Decimal 7 | 8 | 9 | class GitModelUnicodeDecodeError(UnicodeDecodeError): 10 | def __init__(self, obj, *args): 11 | self.obj = obj 12 | UnicodeDecodeError.__init__(self, *args) 13 | 14 | def __str__(self): 15 | original = UnicodeDecodeError.__str__(self) 16 | msg = '{0}. You passed in {1!r} ({2})' 17 | return msg.format(original, self.obj, type(self.obj)) 18 | 19 | 20 | def is_protected_type(obj): 21 | """Determine if the object instance is of a protected type. 22 | 23 | Objects of protected types are preserved as-is when passed to 24 | force_unicode(strings_only=True). 25 | """ 26 | return isinstance(obj, ( 27 | types.NoneType, 28 | int, long, 29 | datetime.datetime, datetime.date, datetime.time, 30 | float, Decimal) 31 | ) 32 | 33 | 34 | def force_unicode(s, encoding='utf-8', strings_only=False, errors='strict'): 35 | """ 36 | Similar to smart_unicode, except that lazy instances are resolved to 37 | strings, rather than kept as lazy objects. 38 | 39 | If strings_only is True, don't convert (some) non-string-like objects. 40 | """ 41 | # Handle the common case first, saves 30-40% in performance when s 42 | # is an instance of unicode. This function gets called often in that 43 | # setting. 44 | if isinstance(s, unicode): 45 | return s 46 | if strings_only and is_protected_type(s): 47 | return s 48 | try: 49 | if not isinstance(s, basestring,): 50 | if hasattr(s, '__unicode__'): 51 | s = unicode(s) 52 | else: 53 | try: 54 | s = unicode(str(s), encoding, errors) 55 | except UnicodeEncodeError: 56 | if not isinstance(s, Exception): 57 | raise 58 | # If we get to here, the caller has passed in an Exception 59 | # subclass populated with non-ASCII data without special 60 | # handling to display as a string. We need to handle this 61 | # without raising a further exception. We do an 62 | # approximation to what the Exception's standard str() 63 | # output should be. 64 | s = u' '.join([force_unicode(arg, encoding, strings_only, 65 | errors) for arg in s]) 66 | elif not isinstance(s, unicode): 67 | # Note: We use .decode() here, instead of unicode(s, encoding, 68 | # errors), so that if s is a SafeString, it ends up being a 69 | # SafeUnicode at the end. 70 | s = s.decode(encoding, errors) 71 | except UnicodeDecodeError, e: 72 | if not isinstance(s, Exception): 73 | raise GitModelUnicodeDecodeError(s, *e.args) 74 | else: 75 | # If we get to here, the caller has passed in an Exception 76 | # subclass populated with non-ASCII bytestring data without a 77 | # working unicode method. Try to handle this without raising a 78 | # further exception by individually forcing the exception args 79 | # to unicode. 80 | s = u' '.join([force_unicode(arg, encoding, strings_only, 81 | errors) for arg in s]) 82 | return s 83 | -------------------------------------------------------------------------------- /gitmodel/utils/isodate.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | from datetime import datetime, time as dt_time 4 | from dateutil import tz 5 | 6 | ISO_DATE_RE = re.compile(r'^\d{4}-\d{1,2}-\d{1,2}$') 7 | ISO_TIME_RE = re.compile(r'^(\d{1,2}:\d{2})(:(\d{2})(\.\d{1,5})?)?' 8 | r'(Z|[+-]\d{1,2}:?\d{2}?)?$') 9 | ISO_DATETIME_RE = re.compile(r'^(\d{4}-\d{1,2}-\d{1,2}[T\s]\d{1,2}:\d{2})(:' 10 | r'(\d{2})(\.\d{1,6})?)?(Z|[+-]\d{1,2}:?' 11 | r'\d{2}?)?$') 12 | TZ_RE = re.compile(r'([+-])(\d{1,2}):?(\d{2})?') 13 | 14 | 15 | class InvalidFormat(Exception): 16 | pass 17 | 18 | 19 | class InvalidDate(Exception): 20 | pass 21 | 22 | 23 | def parse_iso_date(value): 24 | #NEEDS-TEST 25 | if not ISO_DATE_RE.match(value): 26 | raise InvalidFormat('invalid ISO-8601 date: "{}"'.format(value)) 27 | try: 28 | return datetime(*time.strptime(value, '%Y-%m-%d')[:3]).date() 29 | except ValueError: 30 | raise InvalidDate('invalid date: "{}"'.format(value)) 31 | 32 | 33 | def parse_tz(tzstr): 34 | #NEEDS-TEST 35 | # get tz data 36 | if tzstr is None: 37 | tzinfo = None 38 | elif tzstr == 'Z': 39 | tzinfo = tz.tzutc() 40 | else: 41 | # parse offset string 42 | s, h, m = TZ_RE.match(tzstr).groups() 43 | tzseconds = int(m and m or 0) * 60 44 | tzseconds += int(h) * 60 * 60 45 | if s == '-': 46 | tzseconds = tzseconds * -1 47 | tzinfo = tz.tzoffset(None, tzseconds) 48 | return tzinfo 49 | 50 | 51 | def parse_iso_datetime(value): 52 | #NEEDS-TEST 53 | match = ISO_DATETIME_RE.match(value) 54 | if not match: 55 | raise InvalidFormat('invalid ISO-8601 date/time: "{}"'.format(value)) 56 | 57 | # split out into datetime, secs, usecs, and tz 58 | dtstr = match.group(1) 59 | secs = match.group(3) 60 | usecs = match.group(4) 61 | tzstr = match.group(5) 62 | 63 | # replace the "T" if given 64 | dtstr = dtstr.replace('T', ' ') 65 | try: 66 | dt_args = time.strptime(dtstr, '%Y-%m-%d %H:%M')[:5] 67 | except ValueError: 68 | raise InvalidDate('invalid date: "{}"'.format(value)) 69 | 70 | # append seconds, usecs, and tz 71 | dt_args += (int(secs) if secs else 0,) 72 | dt_args += (int(usecs.lstrip('.')) if usecs else 0,) 73 | dt_args += (parse_tz(tzstr),) 74 | 75 | try: 76 | return datetime(*dt_args) 77 | except ValueError: 78 | raise InvalidDate('invalid date: "{}"'.format(value)) 79 | 80 | 81 | def parse_iso_time(value): 82 | #NEEDS-TEST 83 | match = ISO_TIME_RE.match(value) 84 | if not match: 85 | raise InvalidFormat('invalid ISO-8601 time: "{}"'.format(value)) 86 | 87 | # split out into time, secs, usecs, and tz 88 | tmstr = match.group(1) 89 | secs = match.group(3) 90 | usecs = match.group(4) 91 | tzstr = match.group(5) 92 | 93 | try: 94 | dt_args = time.strptime(tmstr, '%H:%M')[3:5] 95 | except ValueError: 96 | raise InvalidDate('invalid time: "{}"'.format(value)) 97 | 98 | # append seconds, usecs, and tz 99 | dt_args += (int(secs) if secs else 0,) 100 | dt_args += (int(usecs) if usecs else 0,) 101 | dt_args += (parse_tz(tzstr),) 102 | 103 | try: 104 | return dt_time(*dt_args) 105 | except ValueError: 106 | raise InvalidDate('invalid date: "{}"'.format(value)) 107 | -------------------------------------------------------------------------------- /gitmodel/utils/path.py: -------------------------------------------------------------------------------- 1 | import fnmatch 2 | import os 3 | import re 4 | import sys 5 | 6 | import pygit2 7 | 8 | 9 | __all__ = ['describe_tree', 'build_path', 'glob'] 10 | 11 | 12 | def build_path(repo, path, entries=None, root=None): 13 | """ 14 | Builds out a tree path, starting with the leaf node, and updating all 15 | trees up the parent chain, resulting in (potentially) a new OID for the 16 | root tree. 17 | 18 | If ``entries`` is provided, those entries are inserted (or updated) 19 | in the tree for the given path. 20 | 21 | If ``root`` is provided, the path will be built based off of that 22 | tree. Otherwise, it is built off of an empty tree. Accepts an OID or a 23 | pygit2.Tree object. 24 | 25 | The root tree OID is returned, so that it can be included in a commit 26 | or stage. 27 | """ 28 | path = path.strip(os.path.sep) 29 | if path is not None and path != '': 30 | parent, name = os.path.split(path) 31 | else: 32 | parent, name = None, None 33 | 34 | if root is None: 35 | # use an empty tree 36 | root_id = repo.TreeBuilder().write() 37 | root = repo[root_id] 38 | 39 | if isinstance(root, (basestring, pygit2.Oid)): 40 | root = repo[root] 41 | 42 | if parent is None: 43 | # we're at the root tree 44 | tb_args = (root.oid,) 45 | else: 46 | # see if current path exists 47 | try: 48 | tree = root[path] 49 | except KeyError: 50 | tb_args = () 51 | else: 52 | tb_args = (tree.oid,) 53 | 54 | # build tree 55 | tb = repo.TreeBuilder(*tb_args) 56 | 57 | for entry in entries: 58 | tb.insert(*entry) 59 | oid = tb.write() 60 | 61 | if parent is None: 62 | # we're at the root tree 63 | return oid 64 | 65 | entry = (name, oid, pygit2.GIT_FILEMODE_TREE) 66 | 67 | if parent == '': 68 | # parent is the root tree 69 | return build_path(repo, '', (entry,), root) 70 | 71 | return build_path(repo, parent, (entry,), root) 72 | 73 | 74 | def describe_tree(repo, tree, indent=2, lvl=0): 75 | """ 76 | Returns a string representation of the given tree, recursively. 77 | """ 78 | output = [] 79 | if isinstance(tree, pygit2.Oid): 80 | tree = repo[tree] 81 | for e in tree: 82 | i = ' ' * indent * lvl 83 | is_tree = repo[e.oid].type == pygit2.GIT_OBJ_TREE 84 | slash = is_tree and '/' or '' 85 | output.append('{}{}{}'.format(i, e.name, slash)) 86 | if is_tree: 87 | sub_items = describe_tree(repo, e.oid, indent, lvl + 1) 88 | output.extend(sub_items) 89 | if lvl == 0: 90 | return '\n'.join(output) 91 | return output 92 | 93 | 94 | def glob(repo, tree, pathname): 95 | """ 96 | Return an iterator which yields the paths matching a pathname pattern. 97 | 98 | This is identical to python's glob.iglob() function, but works on the 99 | given git tree object instead of the filesystem. 100 | """ 101 | if isinstance(tree, pygit2.Oid): 102 | tree = repo[tree] 103 | 104 | pathname = pathname.strip('/') 105 | if not has_magic(pathname): 106 | if path_exists(tree, pathname): 107 | yield pathname 108 | return 109 | 110 | dirname, basename = os.path.split(pathname) 111 | if not dirname: 112 | for name in glob1(repo, tree, os.curdir, basename): 113 | yield name 114 | return 115 | # `os.path.split()` returns the argument itself as a dirname if it is a 116 | # drive or UNC path. Prevent an infinite recursion if a drive or UNC path 117 | # contains magic characters (i.e. r'\\?\C:'). 118 | if dirname != pathname and has_magic(dirname): 119 | dirs = glob(repo, tree, dirname) 120 | else: 121 | dirs = [dirname] 122 | if has_magic(basename): 123 | glob_in_dir = glob1 124 | else: 125 | glob_in_dir = glob0 126 | for dirname in dirs: 127 | for name in glob_in_dir(repo, tree, dirname, basename): 128 | yield os.path.join(dirname, name) 129 | 130 | # These 2 helper functions non-recursively glob inside a literal directory. 131 | # They return a list of basenames. `glob1` accepts a pattern while `glob0` 132 | # takes a literal basename (so it only has to check for its existence). 133 | 134 | 135 | def glob1(repo, tree, dirname, pattern): 136 | if not dirname: 137 | dirname = os.curdir 138 | if isinstance(pattern, unicode) and not isinstance(dirname, unicode): 139 | dirname = unicode(dirname, sys.getfilesystemencoding() or 140 | sys.getdefaultencoding()) 141 | if dirname != os.curdir: 142 | try: 143 | tree = repo[tree[dirname].oid] 144 | except KeyError: 145 | return [] 146 | names = [e.name for e in tree] 147 | if pattern[0] != '.': 148 | names = filter(lambda n: n[0] != '.', names) 149 | return fnmatch.filter(names, pattern) 150 | 151 | 152 | def glob0(repo, tree, dirname, basename): 153 | if basename == '': 154 | # `os.path.split()` returns an empty basename for paths ending with a 155 | # directory separator. 'q*x/' should match only directories. 156 | if path_exists(tree, dirname): 157 | entry = tree[dirname] 158 | if repo[entry.oid].type == pygit2.GIT_OBJ_TREE: 159 | return [basename] 160 | else: 161 | if path_exists(tree, os.path.join(dirname, basename)): 162 | return [basename] 163 | return [] 164 | 165 | 166 | magic_check = re.compile('[*?[]') 167 | 168 | 169 | def has_magic(s): 170 | return magic_check.search(s) is not None 171 | 172 | 173 | def path_exists(tree, path): 174 | try: 175 | tree[path] 176 | except KeyError: 177 | return False 178 | return True 179 | 180 | 181 | def walk(repo, tree, topdown=True): 182 | """ 183 | Similar to os.walk(), using the given tree as a reference point. 184 | """ 185 | names = lambda entries: [e.name for e in entries] 186 | 187 | dirs, nondirs = [], [] 188 | for e in tree: 189 | is_tree = repo[e.oid].type == pygit2.GIT_OBJ_TREE 190 | if is_tree: 191 | dirs.append(e) 192 | else: 193 | nondirs.append(e) 194 | 195 | if topdown: 196 | yield tree, names(dirs), names(nondirs) 197 | for entry in dirs: 198 | new_tree = repo[entry.oid] 199 | for x in walk(repo, new_tree, topdown): 200 | yield x 201 | if not topdown: 202 | yield tree, names(dirs), names(nondirs) 203 | -------------------------------------------------------------------------------- /gitmodel/workspace.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from importlib import import_module 3 | from time import time 4 | import logging 5 | import os 6 | 7 | import pygit2 8 | 9 | from gitmodel import conf 10 | from gitmodel import exceptions 11 | from gitmodel import models 12 | from gitmodel import utils 13 | 14 | 15 | class Workspace(object): 16 | """ 17 | A workspace acts as an encapsulation within which any model work is done. 18 | It is analogous to a git working directory. It also acts as a "porcelain" 19 | layer to pygit2's "plumbing". 20 | 21 | In contrast to a working directory, this class does not make use of the 22 | repository's INDEX and HEAD files, and instead keeps track of the these in 23 | memory. 24 | 25 | Passing initial_branch will set the default head for the workspace. 26 | """ 27 | def __init__(self, repo_path, initial_branch='refs/heads/master'): 28 | self.config = conf.Config() 29 | 30 | # set up a model registry 31 | class ModelRegistry(dict): 32 | """This class acts like a so-called AttrDict""" 33 | def __init__(self): 34 | self.__dict__ = self 35 | 36 | self.models = ModelRegistry() 37 | 38 | try: 39 | self.repo = pygit2.Repository(repo_path) 40 | except KeyError: 41 | msg = "Git repository not found at {}".format(repo_path) 42 | raise exceptions.RepositoryNotFound(msg) 43 | 44 | self.index = None 45 | 46 | # set default head 47 | self.head = initial_branch 48 | 49 | # Set branch to head. If it the branch (head commit) doesn't exist, set 50 | # index to a new empty tree. 51 | try: 52 | self.repo.lookup_reference(self.head) 53 | except KeyError: 54 | oid = self.repo.TreeBuilder().write() 55 | self.index = self.repo[oid] 56 | else: 57 | self.update_index(self.head) 58 | 59 | # add a base GitModel which can be extended if needed 60 | self.register_model(models.GitModel, 'GitModel') 61 | 62 | self.log = logging.getLogger(__name__) 63 | 64 | def register_model(self, cls, name=None): 65 | """ 66 | Register a GitModel class with this workspace. A GitModel cannot be 67 | used until it is registered with a workspace. This does not alter the 68 | origingal class, but rather creates a "clone" which is bound to this 69 | workspace. If a model attribute requires special handling for the 70 | cloning, that object should define a "contribute_to_class" method. 71 | 72 | Note that when a GitModel with any RelatedFields is registered, its 73 | related models will be automatically registered with the same workspace 74 | if they have not already been registered with a workspace. 75 | """ 76 | if not issubclass(cls, models.GitModel): 77 | raise TypeError("{0!r} is not a GitModel.".format(cls)) 78 | 79 | if not name: 80 | name = cls.__name__ 81 | 82 | if self.models.get(name): 83 | return self.models[name] 84 | 85 | if hasattr(cls, '_meta'): 86 | if cls._meta.workspace != self: 87 | msg = "{0} is already registered with a different workspace" 88 | raise ValueError(msg.format(cls.__name__)) 89 | # class has already been created with _meta, so we just register 90 | # and return it. 91 | self.models[name] = cls 92 | return cls 93 | 94 | metaclass = models.DeclarativeMetaclass 95 | attrs = dict(cls.__dict__, **{ 96 | '__workspace__': self, 97 | }) 98 | if not attrs.get('__module__'): 99 | attrs['__module__'] == __name__ 100 | 101 | if attrs.get('__dict__'): 102 | del attrs['__dict__'] 103 | 104 | # the cloned model must subclass the original so as not to break 105 | # type-checking operations 106 | bases = [cls] 107 | 108 | # parents must also be registered with the workspace 109 | for base in cls.__bases__: 110 | if issubclass(base, models.GitModel) and \ 111 | not hasattr(base, '_meta'): 112 | base = self.models.get(name) or self.register_model(base) 113 | bases.append(base) 114 | 115 | # create the new class and attach it to the workspace 116 | new_model = metaclass(name, tuple(bases), attrs) 117 | self.models[name] = new_model 118 | return new_model 119 | 120 | def import_models(self, path_or_module): 121 | """ 122 | Register all models declared within a given python module 123 | """ 124 | if isinstance(path_or_module, basestring): 125 | mod = import_module(path_or_module) 126 | else: 127 | mod = path_or_module 128 | 129 | for name in dir(mod): 130 | item = getattr(mod, name) 131 | if isinstance(item, type) and \ 132 | issubclass(item, models.GitModel): 133 | self.register_model(item, name) 134 | 135 | return self.models 136 | 137 | def create_blob(self, content): 138 | return self.repo.create_blob(content) 139 | 140 | def create_branch(self, name, start_point=None): 141 | """ 142 | Creates a head reference with the given name. The start_point argument 143 | is the head to which the new branch will point -- it may be a branch 144 | name, commit id, or tag name (defaults to current branch). 145 | """ 146 | if not start_point: 147 | start_point = self.head 148 | start_point_ref = self.repo.lookup_reference(start_point) 149 | 150 | if start_point_ref.type != pygit2.GIT_OBJ_COMMIT: 151 | raise ValueError('Given reference must point to a commit') 152 | 153 | branch_ref = 'refs/heads/{}'.format(name) 154 | self.repo.create_reference(branch_ref, start_point_ref.target) 155 | 156 | def get_branch(self, ref_name): 157 | return Branch(self.repo, ref_name) 158 | 159 | def set_branch(self, name): 160 | """ 161 | Sets the current head ref to the given branch name 162 | """ 163 | # make sure the branch is a valid head ref 164 | ref = 'refs/heads/{}'.format(name) 165 | self.repo.lookup_reference(ref) 166 | self.update_index(ref) 167 | 168 | @property 169 | def branch(self): 170 | try: 171 | return self.get_branch(self.head) 172 | except exceptions.RepositoryError: 173 | return None 174 | 175 | def update_index(self, treeish): 176 | """Sets the index to the current branch or to the given treeish""" 177 | # Don't change the index if there are pending changes. 178 | if self.index and self.has_changes(): 179 | msg = "Cannot checkout a different branch with pending changes" 180 | raise exceptions.RepositoryError(msg) 181 | 182 | tree = utils.treeish_to_tree(self.repo, treeish) 183 | 184 | if treeish.startswith('refs/heads'): 185 | # if treeish is a head ref, update head 186 | self.head = treeish 187 | else: 188 | # otherwise, we're in "detached head" mode 189 | self.head = None 190 | 191 | self.index = tree 192 | 193 | def add(self, path, entries): 194 | """ 195 | Updates the current index given a path and a list of entries 196 | """ 197 | oid = utils.path.build_path(self.repo, path, entries, self.index) 198 | self.index = self.repo[oid] 199 | 200 | def remove(self, path): 201 | """ 202 | Removes an item from the index 203 | """ 204 | parent, name = os.path.split(path) 205 | parent_tree = parent and self.index[parent] or self.index 206 | tb = self.repo.TreeBuilder(parent_tree.oid) 207 | tb.remove(name) 208 | oid = tb.write() 209 | if parent: 210 | path, parent_name = os.path.split(parent) 211 | entry = (parent_name, oid, pygit2.GIT_FILEMODE_TREE) 212 | oid = utils.path.build_path(self.repo, path, [entry], self.index) 213 | self.index = self.repo[oid] 214 | 215 | def add_blob(self, path, content, mode=pygit2.GIT_FILEMODE_BLOB): 216 | """ 217 | Creates a blob object and adds it to the current index 218 | """ 219 | path, name = os.path.split(path) 220 | blob = self.repo.create_blob(content) 221 | entry = (name, blob, mode) 222 | self.add(path, [entry]) 223 | return blob 224 | 225 | @contextmanager 226 | def commit_on_success(self, message='', author=None, committer=None): 227 | """ 228 | A context manager that allows you to wrap a block of changes and commit 229 | those changes if no exceptions occur. This also ensures that the 230 | repository is in a clean state (i.e., no changes) before allowing any 231 | further changes. 232 | """ 233 | # ensure a clean state 234 | if self.has_changes(): 235 | msg = "Repository has pending changes. Cannot auto-commit until "\ 236 | "pending changes have been comitted." 237 | raise exceptions.RepositoryError(msg) 238 | 239 | yield 240 | 241 | self.commit(message, author, committer) 242 | 243 | def diff(self): 244 | """ 245 | Returns a pygit2.Diff object representing a diff between the current 246 | index and the current branch. 247 | """ 248 | if self.branch: 249 | tree = self.branch.tree 250 | else: 251 | empty_tree = self.repo.TreeBuilder().write() 252 | tree = self.repo[empty_tree] 253 | return tree.diff_to_tree(self.index) 254 | 255 | def has_changes(self): 256 | """Returns True if the current tree differs from the current branch""" 257 | # As of pygit2 0.19, Diff.patch seems to raise a non-descript GitError 258 | # if there are no changes, so we check the iterable length instead. 259 | return len(tuple(self.diff())) > 0 260 | 261 | def commit(self, message='', author=None, committer=None): 262 | """Commits the current tree to the current branch.""" 263 | if not self.has_changes(): 264 | return None 265 | parents = [] 266 | if self.branch: 267 | parents = [self.branch.commit.oid] 268 | return self.create_commit(self.head, self.index, message, author, 269 | committer, parents) 270 | 271 | def create_commit(self, ref, tree, message='', author=None, 272 | committer=None, parents=None): 273 | """ 274 | Create a commit with the given ref, tree, and message. If parent 275 | commits are not given, the commit pointed to by the given ref is used 276 | as the parent. If author and commitor are not given, the defaults in 277 | the config are used. 278 | """ 279 | if not author: 280 | author = self.config.DEFAULT_GIT_USER 281 | if not committer: 282 | committer = author 283 | 284 | default_offset = self.config.get('DEFAULT_TZ_OFFSET', None) 285 | author = utils.make_signature(*author, default_offset=default_offset) 286 | committer = utils.make_signature(*committer, 287 | default_offset=default_offset) 288 | 289 | if parents is None: 290 | try: 291 | parent_ref = self.repo.lookup_reference(ref) 292 | except KeyError: 293 | parents = [] # initial commit 294 | else: 295 | parents = [parent_ref.oid] 296 | 297 | # FIXME: create_commit updates the HEAD ref. HEAD isn't used in 298 | # gitmodel, however it would be prudent to make sure it doesn't 299 | # get changed. Possibly need to just restore it after the commit 300 | return self.repo.create_commit(ref, author, committer, message, 301 | tree.oid, parents) 302 | 303 | def walk(self, sort=pygit2.GIT_SORT_TIME): 304 | """Iterate through commits on the current branch""" 305 | #NEEDS-TEST 306 | for commit in self.repo.walk(self.branch.oid, sort): 307 | yield commit 308 | 309 | @contextmanager 310 | def lock(self, id): 311 | """ 312 | Acquires a lock with the given id. Uses an empty reference to store the 313 | lock state, eg: refs/locks/my-lock 314 | """ 315 | start_time = time() 316 | while self.locked(id): 317 | if time() - start_time > self.config.LOCK_WAIT_TIMEOUT: 318 | msg = ("Lock wait timeout exceeded while trying to acquire " 319 | "lock '{}' on {}").format(id, self.path) 320 | raise exceptions.LockWaitTimeoutExceeded(msg) 321 | time.sleep(self.config.LOCK_WAIT_INTERVAL) 322 | 323 | # The blob itself is not important, just the fact that the ref exists 324 | emptyblob = self.create_blob('') 325 | ref = self.repo.create_reference('refs/locks/{}'.format(id), emptyblob) 326 | yield 327 | ref.delete() 328 | 329 | def locked(self, id): 330 | try: 331 | self.repo.lookup_reference('refs/locks/{}'.format(id)) 332 | except KeyError: 333 | return False 334 | return True 335 | 336 | def sync_repo_index(self, checkout=True): 337 | """ 338 | Updates the git repository's index with the current workspace index. 339 | If ``checkout`` is ``True``, the filesystem will be updated with the 340 | contents of the index. 341 | 342 | This is useful if you want to utilize the git repository using standard 343 | git tools. 344 | 345 | This function acquires a workspace-level INDEX lock. 346 | """ 347 | with self.lock('INDEX'): 348 | self.repo.index.read_tree(self.index.oid) 349 | if checkout: 350 | self.repo.checkout() 351 | 352 | 353 | class Branch(object): 354 | """ 355 | A representation of a git branch that provides quick access to the ref, 356 | commit, and commit tree. 357 | """ 358 | def __init__(self, repo, ref_name): 359 | try: 360 | self.ref = repo.lookup_reference(ref_name) 361 | except KeyError: 362 | msg = "Reference not found: {}".format(ref_name) 363 | raise exceptions.RepositoryError(msg) 364 | self.commit = self.ref.get_object() 365 | self.oid = self.commit.oid 366 | self.tree = self.commit.tree 367 | 368 | def __getitem__(self, path): 369 | return self.tree[path] 370 | -------------------------------------------------------------------------------- /run-tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | import os 4 | 5 | sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) 6 | 7 | from gitmodel.test import main 8 | 9 | if __name__ == '__main__': 10 | main() 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | name='python-gitmodel', 5 | version='0.1dev', 6 | test_suite='gitmodel.test', 7 | packages=[ 8 | 'gitmodel', 9 | 'gitmodel.serializers', 10 | 'gitmodel.utils', 11 | ], 12 | install_requires=['pygit2', 'python-dateutil', 'decorator'], 13 | license='Creative Commons Attribution-Noncommercial-Share Alike license', 14 | long_description=open('README.rst').read(), 15 | ) 16 | --------------------------------------------------------------------------------