├── .coveragerc ├── .gitattributes ├── .gitignore ├── .pycodestyle.ini ├── .pydocstyle.ini ├── .pylint.ini ├── .python-version ├── .scrutinizer.yml ├── .travis.yml ├── .verchew.ini ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── MANIFEST.in ├── Makefile ├── Pipfile ├── Pipfile.lock ├── README.md ├── bin ├── checksum ├── open └── verchew ├── docs ├── about │ ├── changelog.md │ ├── contributing.md │ └── license.md ├── api │ ├── builtin-types.md │ ├── class-mapping.md │ ├── custom-types.md │ ├── instance-mapping.md │ └── utilities.md └── index.md ├── examples ├── demo.ipynb └── students │ └── .gitignore ├── mkdocs.yml ├── scent.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── files │ └── my_key │ │ └── config.yml ├── test_examples.py ├── test_fake.py ├── test_files.py ├── test_imports.py ├── test_list_shortcut.py ├── test_mapped_signature.py ├── test_nested_attributes.py ├── test_ordering.py └── test_persistence_models.py └── yorm ├── __init__.py ├── bases ├── __init__.py ├── converter.py └── mappable.py ├── common.py ├── decorators.py ├── diskutils.py ├── exceptions.py ├── mapper.py ├── mixins.py ├── settings.py ├── tests ├── __init__.py ├── conftest.py ├── test_bases_container.py ├── test_bases_converter.py ├── test_bases_mappable.py ├── test_decorators.py ├── test_diskutils.py ├── test_mapper.py ├── test_mixins.py ├── test_types_containers.py ├── test_types_extended.py ├── test_types_standard.py └── test_utilities.py ├── types ├── __init__.py ├── _representers.py ├── containers.py ├── extended.py └── standard.py └── utilities.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | 3 | branch = true 4 | 5 | omit = 6 | .venv/* 7 | */tests/* 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | CHANGELOG.md merge=union 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary Python files 2 | *.pyc 3 | *.egg-info 4 | __pycache__ 5 | .ipynb_checkpoints 6 | 7 | # Temporary OS files 8 | Icon* 9 | 10 | # Temporary virtual environment files 11 | /.*cache/ 12 | /.venv/ 13 | 14 | # Temporary server files 15 | .env 16 | *.pid 17 | 18 | # Generated documentation 19 | /docs/gen/ 20 | /docs/apidocs/ 21 | /site/ 22 | /*.html 23 | /*.rst 24 | /docs/*.png 25 | 26 | # Google Drive 27 | *.gdoc 28 | *.gsheet 29 | *.gslides 30 | *.gdraw 31 | 32 | # Testing and coverage results 33 | /.coverage 34 | /.coverage.* 35 | /htmlcov/ 36 | /xmlreport/ 37 | /pyunit.xml 38 | /tmp/ 39 | *.tmp 40 | 41 | # Build and release directories 42 | /build/ 43 | /dist/ 44 | *.spec 45 | 46 | # Sublime Text 47 | *.sublime-workspace 48 | 49 | # Eclipse 50 | .settings 51 | -------------------------------------------------------------------------------- /.pycodestyle.ini: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | 3 | # E401 multiple imports on one line (checked by PyLint) 4 | # E402 module level import not at top of file (checked by PyLint) 5 | # E501: line too long (checked by PyLint) 6 | # E701: multiple statements on one line (used to shorten test syntax) 7 | # E711: comparison to None (used to improve test style) 8 | # E712: comparison to True (used to improve test style) 9 | ignore = E401,E402,E501,E701,E711,E712 10 | -------------------------------------------------------------------------------- /.pydocstyle.ini: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | 3 | # D211: No blank lines allowed before class docstring 4 | add_select = D211 5 | 6 | # D100: Missing docstring in public module 7 | # D101: Missing docstring in public class 8 | # D102: Missing docstring in public method 9 | # D103: Missing docstring in public function 10 | # D104: Missing docstring in public package 11 | # D105: Missing docstring in magic method 12 | # D107: Missing docstring in __init__ 13 | # D202: No blank lines allowed after function docstring 14 | add_ignore = D100,D101,D102,D103,D104,D105,D107,D202 15 | -------------------------------------------------------------------------------- /.pylint.ini: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns= 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | #init-hook= 19 | 20 | # Use multiple processes to speed up Pylint. 21 | jobs=1 22 | 23 | # List of plugins (as comma separated values of python modules names) to load, 24 | # usually to register additional checkers. 25 | load-plugins= 26 | 27 | # Pickle collected data for later comparisons. 28 | persistent=yes 29 | 30 | # Specify a configuration file. 31 | #rcfile= 32 | 33 | # Allow loading of arbitrary C extensions. Extensions are imported into the 34 | # active Python interpreter and may run arbitrary code. 35 | unsafe-load-any-extension=no 36 | 37 | 38 | [MESSAGES CONTROL] 39 | 40 | # Only show warnings with the listed confidence levels. Leave empty to show 41 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 42 | confidence= 43 | 44 | # Disable the message, report, category or checker with the given id(s). You 45 | # can either give multiple identifiers separated by comma (,) or put this 46 | # option multiple times (only on the command line, not in the configuration 47 | # file where it should appear only once).You can also use "--disable=all" to 48 | # disable everything first and then reenable specific checks. For example, if 49 | # you want to run only the similarities checker, you can use "--disable=all 50 | # --enable=similarities". If you want to run only the classes checker, but have 51 | # no Warning level messages displayed, use"--disable=all --enable=classes 52 | # --disable=W" 53 | disable= 54 | print-statement, 55 | parameter-unpacking, 56 | unpacking-in-except, 57 | old-raise-syntax, 58 | backtick, 59 | long-suffix, 60 | old-ne-operator, 61 | old-octal-literal, 62 | import-star-module-level, 63 | raw-checker-failed, 64 | bad-inline-option, 65 | locally-disabled, 66 | locally-enabled, 67 | file-ignored, 68 | suppressed-message, 69 | useless-suppression, 70 | deprecated-pragma, 71 | apply-builtin, 72 | basestring-builtin, 73 | buffer-builtin, 74 | cmp-builtin, 75 | coerce-builtin, 76 | execfile-builtin, 77 | file-builtin, 78 | long-builtin, 79 | raw_input-builtin, 80 | reduce-builtin, 81 | standarderror-builtin, 82 | unicode-builtin, 83 | xrange-builtin, 84 | coerce-method, 85 | delslice-method, 86 | getslice-method, 87 | setslice-method, 88 | no-absolute-import, 89 | old-division, 90 | dict-iter-method, 91 | dict-view-method, 92 | next-method-called, 93 | metaclass-assignment, 94 | indexing-exception, 95 | raising-string, 96 | reload-builtin, 97 | oct-method, 98 | hex-method, 99 | nonzero-method, 100 | cmp-method, 101 | input-builtin, 102 | round-builtin, 103 | intern-builtin, 104 | unichr-builtin, 105 | map-builtin-not-iterating, 106 | zip-builtin-not-iterating, 107 | range-builtin-not-iterating, 108 | filter-builtin-not-iterating, 109 | using-cmp-argument, 110 | eq-without-hash, 111 | div-method, 112 | idiv-method, 113 | rdiv-method, 114 | exception-message-attribute, 115 | invalid-str-codec, 116 | sys-max-int, 117 | bad-python3-import, 118 | deprecated-string-function, 119 | deprecated-str-translate-call, 120 | missing-docstring, 121 | invalid-name, 122 | too-few-public-methods, 123 | fixme, 124 | too-many-arguments, 125 | too-many-branches, 126 | too-many-instance-attributes, 127 | too-many-locals, 128 | no-else-return, 129 | arguments-differ, 130 | too-many-statements, 131 | 132 | # Enable the message, report, category or checker with the given id(s). You can 133 | # either give multiple identifier separated by comma (,) or put this option 134 | # multiple time (only on the command line, not in the configuration file where 135 | # it should appear only once). See also the "--disable" option for examples. 136 | enable= 137 | 138 | 139 | [REPORTS] 140 | 141 | # Python expression which should return a note less than 10 (10 is the highest 142 | # note). You have access to the variables errors warning, statement which 143 | # respectively contain the number of errors / warnings messages and the total 144 | # number of statements analyzed. This is used by the global evaluation report 145 | # (RP0004). 146 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 147 | 148 | # Template used to display messages. This is a python new-style format string 149 | # used to format the message information. See doc for all details 150 | #msg-template= 151 | 152 | # Set the output format. Available formats are text, parseable, colorized, json 153 | # and msvs (visual studio).You can also give a reporter class, eg 154 | # mypackage.mymodule.MyReporterClass. 155 | output-format=text 156 | 157 | # Tells whether to display a full report or only the messages 158 | reports=no 159 | 160 | # Activate the evaluation score. 161 | score=no 162 | 163 | 164 | [REFACTORING] 165 | 166 | # Maximum number of nested blocks for function / method body 167 | max-nested-blocks=5 168 | 169 | 170 | [BASIC] 171 | 172 | # Naming hint for argument names 173 | argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 174 | 175 | # Regular expression matching correct argument names 176 | argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 177 | 178 | # Naming hint for attribute names 179 | attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 180 | 181 | # Regular expression matching correct attribute names 182 | attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 183 | 184 | # Bad variable names which should always be refused, separated by a comma 185 | bad-names=foo,bar,baz,toto,tutu,tata 186 | 187 | # Naming hint for class attribute names 188 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 189 | 190 | # Regular expression matching correct class attribute names 191 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 192 | 193 | # Naming hint for class names 194 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 195 | 196 | # Regular expression matching correct class names 197 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 198 | 199 | # Naming hint for constant names 200 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 201 | 202 | # Regular expression matching correct constant names 203 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 204 | 205 | # Minimum line length for functions/classes that require docstrings, shorter 206 | # ones are exempt. 207 | docstring-min-length=-1 208 | 209 | # Naming hint for function names 210 | function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 211 | 212 | # Regular expression matching correct function names 213 | function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 214 | 215 | # Good variable names which should always be accepted, separated by a comma 216 | good-names=i,j,k,ex,Run,_ 217 | 218 | # Include a hint for the correct naming format with invalid-name 219 | include-naming-hint=no 220 | 221 | # Naming hint for inline iteration names 222 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 223 | 224 | # Regular expression matching correct inline iteration names 225 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 226 | 227 | # Naming hint for method names 228 | method-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 229 | 230 | # Regular expression matching correct method names 231 | method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 232 | 233 | # Naming hint for module names 234 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 235 | 236 | # Regular expression matching correct module names 237 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 238 | 239 | # Colon-delimited sets of names that determine each other's naming style when 240 | # the name regexes allow several styles. 241 | name-group= 242 | 243 | # Regular expression which should only match function or class names that do 244 | # not require a docstring. 245 | no-docstring-rgx=^_ 246 | 247 | # List of decorators that produce properties, such as abc.abstractproperty. Add 248 | # to this list to register other decorators that produce valid properties. 249 | property-classes=abc.abstractproperty 250 | 251 | # Naming hint for variable names 252 | variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 253 | 254 | # Regular expression matching correct variable names 255 | variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 256 | 257 | 258 | [FORMAT] 259 | 260 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 261 | expected-line-ending-format= 262 | 263 | # Regexp for a line that is allowed to be longer than the limit. 264 | ignore-long-lines=^.*((https?:)|(pragma:)|(TODO:)).*$ 265 | 266 | # Number of spaces of indent required inside a hanging or continued line. 267 | indent-after-paren=4 268 | 269 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 270 | # tab). 271 | indent-string=' ' 272 | 273 | # Maximum number of characters on a single line. 274 | max-line-length=79 275 | 276 | # Maximum number of lines in a module 277 | max-module-lines=1000 278 | 279 | # List of optional constructs for which whitespace checking is disabled. `dict- 280 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 281 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 282 | # `empty-line` allows space-only lines. 283 | no-space-check=trailing-comma,dict-separator 284 | 285 | # Allow the body of a class to be on the same line as the declaration if body 286 | # contains single statement. 287 | single-line-class-stmt=no 288 | 289 | # Allow the body of an if to be on the same line as the test if there is no 290 | # else. 291 | single-line-if-stmt=no 292 | 293 | 294 | [LOGGING] 295 | 296 | # Logging modules to check that the string format arguments are in logging 297 | # function parameter format 298 | logging-modules=logging 299 | 300 | 301 | [MISCELLANEOUS] 302 | 303 | # List of note tags to take in consideration, separated by a comma. 304 | notes=FIXME,XXX,TODO 305 | 306 | 307 | [SIMILARITIES] 308 | 309 | # Ignore comments when computing similarities. 310 | ignore-comments=yes 311 | 312 | # Ignore docstrings when computing similarities. 313 | ignore-docstrings=yes 314 | 315 | # Ignore imports when computing similarities. 316 | ignore-imports=no 317 | 318 | # Minimum lines number of a similarity. 319 | min-similarity-lines=4 320 | 321 | 322 | [SPELLING] 323 | 324 | # Spelling dictionary name. Available dictionaries: none. To make it working 325 | # install python-enchant package. 326 | spelling-dict= 327 | 328 | # List of comma separated words that should not be checked. 329 | spelling-ignore-words= 330 | 331 | # A path to a file that contains private dictionary; one word per line. 332 | spelling-private-dict-file= 333 | 334 | # Tells whether to store unknown words to indicated private dictionary in 335 | # --spelling-private-dict-file option instead of raising a message. 336 | spelling-store-unknown-words=no 337 | 338 | 339 | [TYPECHECK] 340 | 341 | # List of decorators that produce context managers, such as 342 | # contextlib.contextmanager. Add to this list to register other decorators that 343 | # produce valid context managers. 344 | contextmanager-decorators=contextlib.contextmanager 345 | 346 | # List of members which are set dynamically and missed by pylint inference 347 | # system, and so shouldn't trigger E1101 when accessed. Python regular 348 | # expressions are accepted. 349 | generated-members= 350 | 351 | # Tells whether missing members accessed in mixin class should be ignored. A 352 | # mixin class is detected if its name ends with "mixin" (case insensitive). 353 | ignore-mixin-members=yes 354 | 355 | # This flag controls whether pylint should warn about no-member and similar 356 | # checks whenever an opaque object is returned when inferring. The inference 357 | # can return multiple potential results while evaluating a Python object, but 358 | # some branches might not be evaluated, which results in partial inference. In 359 | # that case, it might be useful to still emit no-member and other checks for 360 | # the rest of the inferred objects. 361 | ignore-on-opaque-inference=yes 362 | 363 | # List of class names for which member attributes should not be checked (useful 364 | # for classes with dynamically set attributes). This supports the use of 365 | # qualified names. 366 | ignored-classes=optparse.Values,thread._local,_thread._local 367 | 368 | # List of module names for which member attributes should not be checked 369 | # (useful for modules/projects where namespaces are manipulated during runtime 370 | # and thus existing member attributes cannot be deduced by static analysis. It 371 | # supports qualified module names, as well as Unix pattern matching. 372 | ignored-modules= 373 | 374 | # Show a hint with possible names when a member name was not found. The aspect 375 | # of finding the hint is based on edit distance. 376 | missing-member-hint=yes 377 | 378 | # The minimum edit distance a name should have in order to be considered a 379 | # similar match for a missing member name. 380 | missing-member-hint-distance=1 381 | 382 | # The total number of similar names that should be taken in consideration when 383 | # showing a hint for a missing member. 384 | missing-member-max-choices=1 385 | 386 | 387 | [VARIABLES] 388 | 389 | # List of additional names supposed to be defined in builtins. Remember that 390 | # you should avoid to define new builtins when possible. 391 | additional-builtins= 392 | 393 | # Tells whether unused global variables should be treated as a violation. 394 | allow-global-unused-variables=yes 395 | 396 | # List of strings which can identify a callback function by name. A callback 397 | # name must start or end with one of those strings. 398 | callbacks=cb_,_cb 399 | 400 | # A regular expression matching the name of dummy variables (i.e. expectedly 401 | # not used). 402 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 403 | 404 | # Argument names that match this expression will be ignored. Default to name 405 | # with leading underscore 406 | ignored-argument-names=_.*|^ignored_|^unused_ 407 | 408 | # Tells whether we should check for unused import in __init__ files. 409 | init-import=no 410 | 411 | # List of qualified module names which can have objects that can redefine 412 | # builtins. 413 | redefining-builtins-modules=six.moves,future.builtins 414 | 415 | 416 | [CLASSES] 417 | 418 | # List of method names used to declare (i.e. assign) instance attributes. 419 | defining-attr-methods=__init__,__new__,setUp 420 | 421 | # List of member names, which should be excluded from the protected access 422 | # warning. 423 | exclude-protected=_asdict,_fields,_replace,_source,_make 424 | 425 | # List of valid names for the first argument in a class method. 426 | valid-classmethod-first-arg=cls 427 | 428 | # List of valid names for the first argument in a metaclass class method. 429 | valid-metaclass-classmethod-first-arg=mcs 430 | 431 | 432 | [DESIGN] 433 | 434 | # Maximum number of arguments for function / method 435 | max-args=5 436 | 437 | # Maximum number of attributes for a class (see R0902). 438 | max-attributes=7 439 | 440 | # Maximum number of boolean expressions in a if statement 441 | max-bool-expr=5 442 | 443 | # Maximum number of branch for function / method body 444 | max-branches=12 445 | 446 | # Maximum number of locals for function / method body 447 | max-locals=15 448 | 449 | # Maximum number of parents for a class (see R0901). 450 | max-parents=7 451 | 452 | # Maximum number of public methods for a class (see R0904). 453 | max-public-methods=20 454 | 455 | # Maximum number of return / yield for function / method body 456 | max-returns=6 457 | 458 | # Maximum number of statements in function / method body 459 | max-statements=50 460 | 461 | # Minimum number of public methods for a class (see R0903). 462 | min-public-methods=2 463 | 464 | 465 | [IMPORTS] 466 | 467 | # Allow wildcard imports from modules that define __all__. 468 | allow-wildcard-with-all=no 469 | 470 | # Analyse import fallback blocks. This can be used to support both Python 2 and 471 | # 3 compatible code, which means that the block might have code that exists 472 | # only in one or another interpreter, leading to false positives when analysed. 473 | analyse-fallback-blocks=no 474 | 475 | # Deprecated modules which should not be used, separated by a comma 476 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 477 | 478 | # Create a graph of external dependencies in the given file (report RP0402 must 479 | # not be disabled) 480 | ext-import-graph= 481 | 482 | # Create a graph of every (i.e. internal and external) dependencies in the 483 | # given file (report RP0402 must not be disabled) 484 | import-graph= 485 | 486 | # Create a graph of internal dependencies in the given file (report RP0402 must 487 | # not be disabled) 488 | int-import-graph= 489 | 490 | # Force import order to recognize a module as part of the standard 491 | # compatibility libraries. 492 | known-standard-library= 493 | 494 | # Force import order to recognize a module as part of a third party library. 495 | known-third-party=enchant 496 | 497 | 498 | [EXCEPTIONS] 499 | 500 | # Exceptions that will emit a warning when being caught. Defaults to 501 | # "Exception" 502 | overgeneral-exceptions=Exception 503 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.7.0 2 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | build: 2 | tests: 3 | override: 4 | - pylint-run --rcfile=.pylint.ini 5 | - py-scrutinizer-run 6 | checks: 7 | python: 8 | code_rating: true 9 | duplicate_code: true 10 | filter: 11 | excluded_paths: 12 | - "*/tests/*" 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.5 4 | - 3.6 5 | matrix: 6 | include: 7 | - python: 3.7 8 | dist: xenial 9 | sudo: true 10 | 11 | cache: 12 | pip: true 13 | directories: 14 | - .venv 15 | 16 | env: 17 | global: 18 | - RANDOM_SEED=0 19 | 20 | before_install: 21 | - pip install pipenv 22 | - make doctor 23 | 24 | install: 25 | - make install 26 | 27 | script: 28 | - make check 29 | - make test 30 | 31 | after_success: 32 | - pip install coveralls scrutinizer-ocular 33 | - coveralls 34 | - ocular 35 | 36 | notifications: 37 | email: 38 | on_success: never 39 | on_failure: never 40 | -------------------------------------------------------------------------------- /.verchew.ini: -------------------------------------------------------------------------------- 1 | [Make] 2 | 3 | cli = make 4 | version = GNU Make 5 | 6 | [Python] 7 | 8 | cli = python 9 | versions = Python 3.3. | Python 3.4. | Python 3.5. | Python 3.6. 10 | 11 | [pipenv] 12 | 13 | cli = pipenv 14 | versions = 10. | 11. 15 | 16 | [pandoc] 17 | 18 | cli = pandoc 19 | version = 1. 20 | optional = true 21 | message = This is only needed to generate the README for PyPI. 22 | 23 | [Graphviz] 24 | 25 | cli = dot 26 | cli_version_arg = -V 27 | version = 2. 28 | optional = true 29 | message = This is only needed to generate UML diagrams for documentation. 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Revision History 2 | 3 | ## 1.6.2 (2019-03-23) 4 | 5 | - Fixed `YAMLLoadWarning` by using `yaml.safe_load()`. 6 | 7 | ## 1.6.1 (2019-03-22) 8 | 9 | - Updated `PyYAML` to `5.1` for security fixes. 10 | 11 | ## 1.6 (2018-09-07) 12 | 13 | - Added `Number` (and `NullableNumber`) type for floats that store as integers when possible. 14 | 15 | ## 1.5.1 (2018-03-07) 16 | 17 | - Fixed the `List` converter to accept tuples as lists. 18 | 19 | ## 1.5 (2017-10-22) 20 | 21 | - Implemented `match` utility (credit: [@astronouth7303](https://github.com/astronouth7303)). 22 | - Including file contents in parse exceptions. 23 | - Added sync parameter `auto_resolve` to clean up file conflicts automatically. 24 | 25 | ## 1.4 (2017-04-02) 26 | 27 | - Removed warnings about calling save/load unnecessarily. 28 | - Now allowing keyword arguments to be passed to class construction via `create` and `find` utilities. 29 | - Now adding additional attributes from `__init__` on `AttributeDictionary`. 30 | - NOTE: For this feature to work, `__init__` must not use positional arguments. 31 | - **DEPRECIATION**: Renamed `ModelMixin.new` to `ModelMixin.create`. 32 | 33 | ## 1.3 (2017-01-24) 34 | 35 | - Optimized the formatting of empty lists to create consistent diffs. 36 | - Added `ModelMixin` to add ORM methods to mapped classes. 37 | 38 | ## 1.2 (2017-01-06) 39 | 40 | - Updated base class to hide `pytest` traceback in wrapped methods. 41 | 42 | ## 1.1 (2016-10-22) 43 | 44 | - Added `data` property to `Mapper` as a hook for other serialization libraries. 45 | 46 | ## 1.0.1 (2016-09-23) 47 | 48 | - Fixed handling of mutation methods on `list` and `dict`. 49 | 50 | ## 1.0 (2016-05-22) 51 | 52 | - Initial stable release. 53 | 54 | ## 0.8.1 (2016-04-28) 55 | 56 | - Now invoking `__init__` in `Dictionary` converters to run custom validations. 57 | 58 | ## 0.8 (2016-04-14) 59 | 60 | - Replaced all utility functions with ORM-like tools. 61 | - Removed the ability to check for existing files in `sync()`. 62 | - Renamed and consolidated custom exceptions. 63 | - Renamed sync parameter `auto=True` to `auto_save=True`. 64 | - Renamed sync parameter `strict=True` to `auto_track=False`. 65 | - Added sync parameter `auto_create` to defer file creation to ORM functions. 66 | 67 | ## 0.7.2 (2016-03-30) 68 | 69 | - Now preserving order of `attr` decorators on `Dictionary` converters. 70 | 71 | ## 0.7.1 (2016-03-30) 72 | 73 | - Updated `String` to fetch `true` and `false` as strings. 74 | 75 | ## 0.7 (2016-03-29) 76 | 77 | - Now preserving order of `attr` decorators. 78 | - Now limiting `attr` decorator to a single argument. 79 | - Added `List.of_type()` factory to create lists with less boilerplate. 80 | 81 | ## 0.6.1 (2015-02-23) 82 | 83 | - Fixed handling of `None` in `NullableString`. 84 | 85 | ## 0.6 (2015-02-23) 86 | 87 | - Added preliminary support for JSON serialization. (credit: @pr0xmeh) 88 | - Renamed `yorm.converters` to `yorm.types`. 89 | - Now maintaining the signature on mapped objects. 90 | - Disabled attribute inference unless `strict=False`. 91 | - Fixed formatting of `String` to only use quotes if absolutely necessary. 92 | 93 | ## 0.5 (2015-09-25) 94 | 95 | - Renamed `yorm.base` to `yorm.bases`. 96 | - Stopped creating files on instantiation when `auto=False`. 97 | - Now automatically storing on fetch after initial store. 98 | 99 | ## 0.4.1 (2015-06-19) 100 | 101 | - Fixed attribute loss in non-`dict` when conversion to `dict`. 102 | - Now automatically adding missing attributes to mapped objects. 103 | 104 | ## 0.4 (2015-05-16) 105 | 106 | - Moved all converters into the `yorm.converters` package. 107 | - Renamed `container` to `containers`. 108 | - Renamed `Converter` to `Convertible` for mutable types 109 | - Added a new `Converter` class for immutable types 110 | - Removed the context manager in mapped objects. 111 | - Fixed automatic mapping of nested attributes. 112 | 113 | ## 0.3.2 (2015-04-07) 114 | 115 | - Fixed object overwrite when calling `utilities.update`. 116 | 117 | ## 0.3.1 (2015-04-06) 118 | 119 | - Fixed infinite recursion with properties that rely on other mapped attributes. 120 | 121 | ## 0.3 (2015-03-10) 122 | 123 | - Updated mapped objects to only read from the filesystem if there are changes. 124 | - Renamed `store` to `sync_object`. 125 | - Renamed `store_instances` to `sync_instances`. 126 | - Renamed `map_attr` to `attr`. 127 | - Added `sync` to call `sync_object` or `sync_instances` as needed. 128 | - Added `update_object` and `update_file` to force synchronization. 129 | - Added `update` to call `update_object` and/or `update_file` as needed. 130 | 131 | ## 0.2.1 (2015-02-12) 132 | 133 | - Container types now extend their builtin type. 134 | - Added `None` extended types with `None` as a default. 135 | - Added `AttributeDictionary` with keys available as attributes. 136 | - Added `SortedList` that sorts when dumped. 137 | 138 | ## 0.2 (2014-11-30) 139 | 140 | - Allowing `map_attr` and `store` to be used together. 141 | - Allowing `Dictionary` containers to be used as attributes. 142 | - Fixed method resolution order for modified classes. 143 | - Added a `yorm.settings.fake` option to bypass the filesystem. 144 | 145 | ## 0.1.1 (2014-10-20) 146 | 147 | - Fixed typos in examples. 148 | 149 | ## 0.1 (2014-09-29) 150 | 151 | - Initial release. 152 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # For Contributors 2 | 3 | ## Setup 4 | 5 | ### Requirements 6 | 7 | * Make: 8 | * Windows: http://mingw.org/download/installer 9 | * Mac: http://developer.apple.com/xcode 10 | * Linux: http://www.gnu.org/software/make 11 | * pipenv: http://docs.pipenv.org 12 | * Pandoc: http://johnmacfarlane.net/pandoc/installing.html 13 | * Graphviz: http://www.graphviz.org/Download.php 14 | 15 | To confirm these system dependencies are configured correctly: 16 | 17 | ```sh 18 | $ make doctor 19 | ``` 20 | 21 | ### Installation 22 | 23 | Install project dependencies into a virtual environment: 24 | 25 | ```sh 26 | $ make install 27 | ``` 28 | 29 | ## Development Tasks 30 | 31 | ### Testing 32 | 33 | Manually run the tests: 34 | 35 | ```sh 36 | $ make test 37 | ``` 38 | 39 | or keep them running on change: 40 | 41 | ```sh 42 | $ make watch 43 | ``` 44 | 45 | > In order to have OS X notifications, `brew install terminal-notifier`. 46 | 47 | ### Documentation 48 | 49 | Build the documentation: 50 | 51 | ```sh 52 | $ make docs 53 | ``` 54 | 55 | ### Static Analysis 56 | 57 | Run linters and static analyzers: 58 | 59 | ```sh 60 | $ make pylint 61 | $ make pycodestyle 62 | $ make pydocstyle 63 | $ make check # includes all checks 64 | ``` 65 | 66 | ## Continuous Integration 67 | 68 | The CI server will report overall build status: 69 | 70 | ```sh 71 | $ make ci 72 | ``` 73 | 74 | ## Release Tasks 75 | 76 | Release to PyPI: 77 | 78 | ```sh 79 | $ make upload 80 | ``` 81 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | **The MIT License (MIT)** 2 | 3 | Copyright © 2014, Jace Browning 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst *.txt *.md 2 | recursive-include docs *.rst *.txt *.md 3 | graft */files 4 | graft */*/files 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Project settings 2 | PROJECT := YORM 3 | PACKAGE := yorm 4 | REPOSITORY := jacebrowning/yorm 5 | 6 | # Project paths 7 | PACKAGES := $(PACKAGE) tests 8 | CONFIG := $(wildcard *.py) 9 | MODULES := $(wildcard $(PACKAGE)/*.py) 10 | 11 | # Virtual environment paths 12 | export PIPENV_VENV_IN_PROJECT=true 13 | export PIPENV_IGNORE_VIRTUALENVS=true 14 | VENV := .venv 15 | 16 | # MAIN TASKS ################################################################## 17 | 18 | SNIFFER := pipenv run sniffer 19 | 20 | .PHONY: all 21 | all: install 22 | 23 | .PHONY: ci 24 | ci: check test ## Run all tasks that determine CI status 25 | 26 | .PHONY: watch 27 | watch: install .clean-test ## Continuously run all CI tasks when files chanage 28 | $(SNIFFER) 29 | 30 | .PHONY: run ## Start the program 31 | run: install 32 | pipenv run python $(PACKAGE)/__main__.py 33 | 34 | # SYSTEM DEPENDENCIES ######################################################### 35 | 36 | .PHONY: doctor 37 | doctor: ## Confirm system dependencies are available 38 | bin/verchew 39 | 40 | # PROJECT DEPENDENCIES ######################################################## 41 | 42 | DEPENDENCIES := $(VENV)/.pipenv-$(shell bin/checksum Pipfile* setup.py) 43 | 44 | .PHONY: install 45 | install: $(DEPENDENCIES) 46 | 47 | $(DEPENDENCIES): 48 | pipenv run python setup.py develop 49 | pipenv install --dev 50 | @ touch $@ 51 | 52 | # CHECKS ###################################################################### 53 | 54 | PYLINT := pipenv run pylint 55 | PYCODESTYLE := pipenv run pycodestyle 56 | PYDOCSTYLE := pipenv run pydocstyle 57 | 58 | .PHONY: check 59 | check: pylint pycodestyle pydocstyle ## Run linters and static analysis 60 | 61 | .PHONY: pylint 62 | pylint: install 63 | $(PYLINT) $(PACKAGES) $(CONFIG) --rcfile=.pylint.ini 64 | 65 | .PHONY: pycodestyle 66 | pycodestyle: install 67 | $(PYCODESTYLE) $(PACKAGES) $(CONFIG) --config=.pycodestyle.ini 68 | 69 | .PHONY: pydocstyle 70 | pydocstyle: install 71 | $(PYDOCSTYLE) $(PACKAGES) $(CONFIG) 72 | 73 | # TESTS ####################################################################### 74 | 75 | PYTEST := pipenv run pytest 76 | COVERAGE := pipenv run coverage 77 | COVERAGESPACE := pipenv run coveragespace 78 | 79 | RANDOM_SEED ?= $(shell date +%s) 80 | FAILURES := .pytest_cache/v/cache/lastfailed 81 | 82 | PYTEST_CORE_OPTIONS := -ra -vv 83 | PYTEST_COV_OPTIONS := --cov=$(PACKAGE) --no-cov-on-fail --cov-report=term-missing:skip-covered --cov-report=html 84 | PYTEST_RANDOM_OPTIONS := --random --random-seed=$(RANDOM_SEED) 85 | 86 | PYTEST_OPTIONS := $(PYTEST_CORE_OPTIONS) $(PYTEST_RANDOM_OPTIONS) 87 | ifndef DISABLE_COVERAGE 88 | PYTEST_OPTIONS += $(PYTEST_COV_OPTIONS) 89 | endif 90 | PYTEST_RERUN_OPTIONS := $(PYTEST_CORE_OPTIONS) --last-failed --exitfirst 91 | 92 | .PHONY: test 93 | test: test-all ## Run unit and integration tests 94 | 95 | .PHONY: test-unit 96 | test-unit: install 97 | @ ( mv $(FAILURES) $(FAILURES).bak || true ) > /dev/null 2>&1 98 | $(PYTEST) $(PYTEST_OPTIONS) $(PACKAGE) 99 | @ ( mv $(FAILURES).bak $(FAILURES) || true ) > /dev/null 2>&1 100 | $(COVERAGESPACE) $(REPOSITORY) unit 101 | 102 | .PHONY: test-int 103 | test-int: install 104 | @ if test -e $(FAILURES); then $(PYTEST) $(PYTEST_RERUN_OPTIONS) tests; fi 105 | @ rm -rf $(FAILURES) 106 | $(PYTEST) $(PYTEST_OPTIONS) tests 107 | $(COVERAGESPACE) $(REPOSITORY) integration 108 | 109 | .PHONY: test-all 110 | test-all: install 111 | @ if test -e $(FAILURES); then $(PYTEST) $(PYTEST_RERUN_OPTIONS) $(PACKAGES); fi 112 | @ rm -rf $(FAILURES) 113 | $(PYTEST) $(PYTEST_OPTIONS) $(PACKAGES) 114 | $(COVERAGESPACE) $(REPOSITORY) overall 115 | 116 | .PHONY: read-coverage 117 | read-coverage: 118 | bin/open htmlcov/index.html 119 | 120 | # DOCUMENTATION ############################################################### 121 | 122 | PYREVERSE := pipenv run pyreverse 123 | MKDOCS := pipenv run mkdocs 124 | 125 | MKDOCS_INDEX := site/index.html 126 | 127 | .PHONY: docs 128 | docs: uml mkdocs ## Generate documentation 129 | 130 | .PHONY: uml 131 | uml: install docs/*.png 132 | docs/*.png: $(MODULES) 133 | $(PYREVERSE) $(PACKAGE) -p $(PACKAGE) -a 1 -f ALL -o png --ignore tests 134 | - mv -f classes_$(PACKAGE).png docs/classes.png 135 | - mv -f packages_$(PACKAGE).png docs/packages.png 136 | 137 | .PHONY: mkdocs 138 | mkdocs: install $(MKDOCS_INDEX) 139 | $(MKDOCS_INDEX): mkdocs.yml docs/*.md 140 | ln -sf `realpath README.md --relative-to=docs` docs/index.md 141 | ln -sf `realpath CHANGELOG.md --relative-to=docs/about` docs/about/changelog.md 142 | ln -sf `realpath CONTRIBUTING.md --relative-to=docs/about` docs/about/contributing.md 143 | ln -sf `realpath LICENSE.md --relative-to=docs/about` docs/about/license.md 144 | $(MKDOCS) build --clean --strict 145 | 146 | .PHONY: mkdocs-live 147 | mkdocs-live: mkdocs 148 | eval "sleep 3; bin/open http://127.0.0.1:8000" & 149 | $(MKDOCS) serve 150 | 151 | # BUILD ####################################################################### 152 | 153 | PYINSTALLER := pipenv run pyinstaller 154 | PYINSTALLER_MAKESPEC := pipenv run pyi-makespec 155 | 156 | DIST_FILES := dist/*.tar.gz dist/*.whl 157 | EXE_FILES := dist/$(PROJECT).* 158 | 159 | .PHONY: build 160 | build: dist 161 | 162 | .PHONY: dist 163 | dist: install $(DIST_FILES) 164 | $(DIST_FILES): $(MODULES) README.rst CHANGELOG.rst 165 | rm -f $(DIST_FILES) 166 | pipenv run python setup.py check --restructuredtext --strict --metadata 167 | pipenv run python setup.py sdist 168 | pipenv run python setup.py bdist_wheel 169 | 170 | %.rst: %.md 171 | pandoc -f markdown_github -t rst -o $@ $< 172 | 173 | .PHONY: exe 174 | exe: install $(EXE_FILES) 175 | $(EXE_FILES): $(MODULES) $(PROJECT).spec 176 | # For framework/shared support: https://github.com/yyuu/pyenv/wiki 177 | $(PYINSTALLER) $(PROJECT).spec --noconfirm --clean 178 | 179 | $(PROJECT).spec: 180 | $(PYINSTALLER_MAKESPEC) $(PACKAGE)/__main__.py --onefile --windowed --name=$(PROJECT) 181 | 182 | # RELEASE ##################################################################### 183 | 184 | TWINE := pipenv run twine 185 | 186 | .PHONY: upload 187 | upload: dist ## Upload the current version to PyPI 188 | git diff --name-only --exit-code 189 | $(TWINE) upload dist/*.* 190 | bin/open https://pypi.org/project/$(PROJECT) 191 | 192 | # CLEANUP ##################################################################### 193 | 194 | .PHONY: clean 195 | clean: .clean-build .clean-docs .clean-test .clean-install ## Delete all generated and temporary files 196 | 197 | .PHONY: clean-all 198 | clean-all: clean 199 | rm -rf $(VENV) 200 | 201 | .PHONY: .clean-install 202 | .clean-install: 203 | find $(PACKAGES) -name '*.pyc' -delete 204 | find $(PACKAGES) -name '__pycache__' -delete 205 | rm -rf *.egg-info 206 | 207 | .PHONY: .clean-test 208 | .clean-test: 209 | rm -rf .cache .pytest .coverage htmlcov 210 | 211 | .PHONY: .clean-docs 212 | .clean-docs: 213 | rm -rf *.rst docs/apidocs *.html docs/*.png site 214 | 215 | .PHONY: .clean-build 216 | .clean-build: 217 | rm -rf *.spec dist build 218 | 219 | # HELP ######################################################################## 220 | 221 | .PHONY: help 222 | help: all 223 | @ grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 224 | 225 | .DEFAULT_GOAL := help 226 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | name = "pypi" 6 | 7 | [requires] 8 | 9 | python_version = "3" 10 | 11 | [packages] 12 | 13 | yorm = { path = ".", editable = true } 14 | 15 | [dev-packages] 16 | 17 | # Linters 18 | pylint = "*" 19 | pycodestyle = "*" 20 | pydocstyle = "*" 21 | 22 | # Testing 23 | pytest = "~=3.7.0" 24 | pytest-describe = "*" 25 | pytest-expecter = "*" 26 | pytest-random = "*" 27 | pytest-cov = "*" 28 | 29 | # Utilities 30 | minilog = "*" 31 | 32 | # Reports 33 | coveragespace = "*" 34 | 35 | # Documentation 36 | mkdocs = "*" 37 | docutils = "*" 38 | pygments = "*" 39 | 40 | # Build 41 | wheel = "*" 42 | pyinstaller = "*" 43 | 44 | # Release 45 | twine = "*" 46 | 47 | # Tooling 48 | sniffer = "*" 49 | pync = { version = "<2.0", sys_platform = "== 'darwin'" } 50 | MacFSEvents = { version = "*", sys_platform = "== 'darwin'" } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](http://img.shields.io/travis/jacebrowning/yorm/master.svg)](https://travis-ci.org/jacebrowning/yorm) 2 | [![Coverage Status](http://img.shields.io/coveralls/jacebrowning/yorm/master.svg)](https://coveralls.io/r/jacebrowning/yorm) 3 | [![Scrutinizer Code Quality](http://img.shields.io/scrutinizer/g/jacebrowning/yorm.svg)](https://scrutinizer-ci.com/g/jacebrowning/yorm/?branch=master) 4 | [![PyPI Version](http://img.shields.io/pypi/v/yorm.svg)](https://pypi.python.org/pypi/yorm) 5 | 6 | # Overview 7 | 8 | YORM enables automatic, bidirectional, human-friendly mappings of object attributes to YAML files. 9 | Uses beyond typical object serialization and relational mapping include: 10 | 11 | * bidirectional conversion between basic YAML and Python types 12 | * attribute creation and type inference for new attributes 13 | * storage of content in text files optimized for version control 14 | * extensible converters to customize formatting on complex classes 15 | 16 | **NOTE**: This project is now in maintenance mode. Please check out [datafiles](https://github.com/jacebrowning/datafiles), its spiritual successor. 17 | 18 | ## Requirements 19 | 20 | * Python 3.5+ 21 | 22 | ## Installation 23 | 24 | Install YORM with pip: 25 | 26 | ```sh 27 | $ pip install YORM 28 | ``` 29 | 30 | or directly from the source code: 31 | 32 | ```sh 33 | $ git clone https://github.com/jacebrowning/yorm.git 34 | $ cd yorm 35 | $ python setup.py install 36 | ``` 37 | 38 | # Usage 39 | 40 | Simply take an existing class: 41 | 42 | ```python 43 | class Student: 44 | def __init__(self, name, school, number, year=2009): 45 | self.name = name 46 | self.school = school 47 | self.number = number 48 | self.year = year 49 | self.gpa = 0.0 50 | ``` 51 | 52 | and define an attribute mapping: 53 | 54 | ```python 55 | import yorm 56 | from yorm.types import String, Integer, Float 57 | 58 | @yorm.attr(name=String, year=Integer, gpa=Float) 59 | @yorm.sync("students/{self.school}/{self.number}.yml") 60 | class Student: 61 | ... 62 | ``` 63 | 64 | Modifications to each object's mapped attributes: 65 | 66 | ```python 67 | >>> s1 = Student("John Doe", "GVSU", 123) 68 | >>> s2 = Student("Jane Doe", "GVSU", 456, year=2014) 69 | >>> s1.gpa = 3 70 | ``` 71 | 72 | are automatically reflected on the filesytem: 73 | 74 | ```sh 75 | $ cat students/GVSU/123.yml 76 | name: John Doe 77 | gpa: 3.0 78 | school: GVSU 79 | year: 2009 80 | ``` 81 | 82 | Modifications and new content in each mapped file: 83 | 84 | ```sh 85 | $ echo "name: John Doe 86 | > gpa: 1.8 87 | > year: 2010 88 | " > students/GVSU/123.yml 89 | ``` 90 | 91 | are automatically reflected in their corresponding object: 92 | 93 | ```python 94 | >>> s1.gpa 95 | 1.8 96 | ``` 97 | -------------------------------------------------------------------------------- /bin/checksum: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | import hashlib 6 | 7 | 8 | def run(paths): 9 | hash_md5 = hashlib.md5() 10 | 11 | for path in paths: 12 | with open(path, 'rb') as f: 13 | for chunk in iter(lambda: f.read(4096), b''): 14 | hash_md5.update(chunk) 15 | 16 | print(hash_md5.hexdigest()) 17 | 18 | 19 | if __name__ == '__main__': 20 | run(sys.argv[1:]) 21 | -------------------------------------------------------------------------------- /bin/open: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | 7 | 8 | COMMANDS = { 9 | 'linux': "open", 10 | 'win32': "cmd /c start", 11 | 'cygwin': "cygstart", 12 | 'darwin': "open", 13 | } 14 | 15 | 16 | def run(path): 17 | command = COMMANDS.get(sys.platform, "open") 18 | os.system(command + ' ' + path) 19 | 20 | 21 | if __name__ == '__main__': 22 | run(sys.argv[-1]) 23 | -------------------------------------------------------------------------------- /bin/verchew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # The MIT License (MIT) 5 | # Copyright © 2016, Jace Browning 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | # 25 | # Source: https://github.com/jacebrowning/verchew 26 | # Documentation: https://verchew.readthedocs.io 27 | # Package: https://pypi.org/project/verchew 28 | 29 | 30 | from __future__ import unicode_literals 31 | 32 | import os 33 | import sys 34 | import argparse 35 | try: 36 | import configparser # Python 3 37 | except ImportError: 38 | import ConfigParser as configparser # Python 2 39 | from collections import OrderedDict 40 | from subprocess import Popen, PIPE, STDOUT 41 | import logging 42 | 43 | __version__ = '1.3' 44 | 45 | PY2 = sys.version_info[0] == 2 46 | CONFIG_FILENAMES = [ 47 | 'verchew.ini', 48 | '.verchew.ini', 49 | '.verchewrc', 50 | '.verchew', 51 | ] 52 | SAMPLE_CONFIG = """ 53 | [Python] 54 | 55 | cli = python 56 | versions = Python 3.5 | Python 3.6 57 | 58 | [Legacy Python] 59 | 60 | cli = python2 61 | version = Python 2.7 62 | 63 | [virtualenv] 64 | 65 | cli = virtualenv 66 | version = 15. 67 | message = Only required with Python 2. 68 | 69 | [Make] 70 | 71 | cli = make 72 | version = GNU Make 73 | optional = true 74 | 75 | """.strip() 76 | STYLE = { 77 | "~": "✔", 78 | "*": "⭑", 79 | "?": "⚠", 80 | "x": "✘", 81 | } 82 | COLOR = { 83 | "x": "\033[91m", # red 84 | "~": "\033[92m", # green 85 | "?": "\033[93m", # yellow 86 | "*": "\033[94m", # cyan 87 | None: "\033[0m", # reset 88 | } 89 | 90 | log = logging.getLogger(__name__) 91 | 92 | 93 | def main(): 94 | args = parse_args() 95 | configure_logging(args.verbose) 96 | 97 | log.debug("PWD: %s", os.getenv('PWD')) 98 | log.debug("PATH: %s", os.getenv('PATH')) 99 | 100 | path = find_config(args.root, generate=args.init) 101 | config = parse_config(path) 102 | 103 | if not check_dependencies(config) and args.exit_code: 104 | sys.exit(1) 105 | 106 | 107 | def parse_args(): 108 | parser = argparse.ArgumentParser() 109 | 110 | version = "%(prog)s v" + __version__ 111 | parser.add_argument('--version', action='version', version=version) 112 | parser.add_argument('-r', '--root', metavar='PATH', 113 | help="specify a custom project root directory") 114 | parser.add_argument('--init', action='store_true', 115 | help="generate a sample configuration file") 116 | parser.add_argument('--exit-code', action='store_true', 117 | help="return a non-zero exit code on failure") 118 | parser.add_argument('-v', '--verbose', action='count', default=0, 119 | help="enable verbose logging") 120 | 121 | args = parser.parse_args() 122 | 123 | return args 124 | 125 | 126 | def configure_logging(count=0): 127 | if count == 0: 128 | level = logging.WARNING 129 | elif count == 1: 130 | level = logging.INFO 131 | else: 132 | level = logging.DEBUG 133 | 134 | logging.basicConfig(level=level, format="%(levelname)s: %(message)s") 135 | 136 | 137 | def find_config(root=None, filenames=None, generate=False): 138 | root = root or os.getcwd() 139 | filenames = filenames or CONFIG_FILENAMES 140 | 141 | path = None 142 | log.info("Looking for config file in: %s", root) 143 | log.debug("Filename options: %s", ", ".join(filenames)) 144 | for filename in os.listdir(root): 145 | if filename in filenames: 146 | path = os.path.join(root, filename) 147 | log.info("Found config file: %s", path) 148 | return path 149 | 150 | if generate: 151 | path = generate_config(root, filenames) 152 | return path 153 | 154 | msg = "No config file found in: {0}".format(root) 155 | raise RuntimeError(msg) 156 | 157 | 158 | def generate_config(root=None, filenames=None): 159 | root = root or os.getcwd() 160 | filenames = filenames or CONFIG_FILENAMES 161 | 162 | path = os.path.join(root, filenames[0]) 163 | 164 | log.info("Generating sample config: %s", path) 165 | with open(path, 'w') as config: 166 | config.write(SAMPLE_CONFIG + '\n') 167 | 168 | return path 169 | 170 | 171 | def parse_config(path): 172 | data = OrderedDict() 173 | 174 | log.info("Parsing config file: %s", path) 175 | config = configparser.ConfigParser() 176 | config.read(path) 177 | 178 | for section in config.sections(): 179 | data[section] = OrderedDict() 180 | for name, value in config.items(section): 181 | data[section][name] = value 182 | 183 | for name in data: 184 | versions = data[name].get('versions', data[name].pop('version', "")) 185 | data[name]['versions'] = versions 186 | data[name]['patterns'] = [v.strip() for v in versions.split('|')] 187 | 188 | return data 189 | 190 | 191 | def check_dependencies(config): 192 | success = [] 193 | 194 | for name, settings in config.items(): 195 | show("Checking for {0}...".format(name), head=True) 196 | output = get_version(settings['cli'], settings.get('cli_version_arg')) 197 | 198 | for pattern in settings['patterns']: 199 | if match_version(pattern, output): 200 | show(_("~") + " MATCHED: {0}".format(pattern)) 201 | success.append(_("~")) 202 | break 203 | else: 204 | if settings.get('optional'): 205 | show(_("?") + " EXPECTED: {0}".format(settings['versions'])) 206 | success.append(_("?")) 207 | else: 208 | show(_("x") + " EXPECTED: {0}".format(settings['versions'])) 209 | success.append(_("x")) 210 | if settings.get('message'): 211 | show(_("*") + " MESSAGE: {0}".format(settings['message'])) 212 | 213 | show("Results: " + " ".join(success), head=True) 214 | 215 | return _("x") not in success 216 | 217 | 218 | def get_version(program, argument=None): 219 | argument = argument or '--version' 220 | args = [program, argument] 221 | 222 | show("$ {0}".format(" ".join(args))) 223 | output = call(args) 224 | show(output.splitlines()[0]) 225 | 226 | return output 227 | 228 | 229 | def match_version(pattern, output): 230 | return output.startswith(pattern) or " " + pattern in output 231 | 232 | 233 | def call(args): 234 | try: 235 | process = Popen(args, stdout=PIPE, stderr=STDOUT) 236 | except OSError: 237 | log.debug("Command not found: %s", args[0]) 238 | output = "sh: command not found: {0}".format(args[0]) 239 | else: 240 | raw = process.communicate()[0] 241 | output = raw.decode('utf-8').strip() 242 | log.debug("Command output: %r", output) 243 | 244 | return output 245 | 246 | 247 | def show(text, start='', end='\n', head=False): 248 | """Python 2 and 3 compatible version of print.""" 249 | if head: 250 | start = '\n' 251 | end = '\n\n' 252 | 253 | if log.getEffectiveLevel() < logging.WARNING: 254 | log.info(text) 255 | else: 256 | formatted = (start + text + end) 257 | if PY2: 258 | formatted = formatted.encode('utf-8') 259 | sys.stdout.write(formatted) 260 | sys.stdout.flush() 261 | 262 | 263 | def _(word, is_tty=None, supports_utf8=None, supports_ansi=None): 264 | """Format and colorize a word based on available encoding.""" 265 | formatted = word 266 | 267 | if is_tty is None: 268 | is_tty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() 269 | if supports_utf8 is None: 270 | supports_utf8 = sys.stdout.encoding == 'UTF-8' 271 | if supports_ansi is None: 272 | supports_ansi = sys.platform != 'win32' or 'ANSICON' in os.environ 273 | 274 | style_support = supports_utf8 275 | color_support = is_tty and supports_ansi 276 | 277 | if style_support: 278 | formatted = STYLE.get(word, word) 279 | 280 | if color_support and COLOR.get(word): 281 | formatted = COLOR[word] + formatted + COLOR[None] 282 | 283 | return formatted 284 | 285 | 286 | if __name__ == '__main__': # pragma: no cover 287 | main() 288 | -------------------------------------------------------------------------------- /docs/about/changelog.md: -------------------------------------------------------------------------------- 1 | ../../CHANGELOG.md -------------------------------------------------------------------------------- /docs/about/contributing.md: -------------------------------------------------------------------------------- 1 | ../../CONTRIBUTING.md -------------------------------------------------------------------------------- /docs/about/license.md: -------------------------------------------------------------------------------- 1 | ../../LICENSE.md -------------------------------------------------------------------------------- /docs/api/builtin-types.md: -------------------------------------------------------------------------------- 1 | This documentation is a work in progress. Please help expand it. 2 | 3 | --- 4 | 5 | # Containers 6 | 7 | YORM has two container types: 8 | 9 | ```python 10 | from yorm.types import List, Dictionary 11 | ``` 12 | 13 | ## List 14 | 15 | The `List` converter stores an attribute containing a sequence of homogenous values and is fetched as a `list`. The base class must extended for use and specify a single mapped attribute named `all`. 16 | 17 | For example: 18 | 19 | ```python 20 | @yorm.attr(all=Float) 21 | class Things(List): 22 | ... 23 | 24 | @yorm.attr(things=Things) 25 | class Stuff: 26 | ... 27 | ``` 28 | 29 | will store the `things` attribute as a list of `float` values: 30 | 31 | ```yaml 32 | things: 33 | - 1.0 34 | - 2.3 35 | ``` 36 | 37 | A shorthand syntax is also available to extend the `List` converter: 38 | 39 | ```python 40 | List.of_type() 41 | ``` 42 | 43 | This is equivalent to the previous example: 44 | 45 | ``` 46 | @yorm.attr(things=List.of_type(Float)) 47 | class Stuff: 48 | ... 49 | ``` 50 | 51 | ## Dictionary 52 | 53 | TBD 54 | -------------------------------------------------------------------------------- /docs/api/class-mapping.md: -------------------------------------------------------------------------------- 1 | # Class Mapping 2 | 3 | Instances of any class can be mapped to the file system. For these examples, a `Student` class will be used: 4 | 5 | ```python 6 | class Student: 7 | def __init__(self, name, school, number, year=2009): 8 | self.name = name 9 | self.school = school 10 | self.number = number 11 | self.year = year 12 | self.gpa = 0.0 13 | ``` 14 | 15 | To map instances of this class to files, apply the `yorm.sync()` decorator to that class' definition: 16 | 17 | ```python 18 | import yorm 19 | 20 | @yorm.sync("students/{self.school}/{self.number}.yml") 21 | class Student: 22 | ... 23 | ``` 24 | 25 | # Attribute Selection 26 | 27 | To control which attributes should be included in the mapping, apply one or more `yorm.attr()` decorators specifying the attribute type: 28 | 29 | ```python 30 | import yorm 31 | from yorm.types import String, Integer, Float 32 | 33 | @yorm.attr(name=String, year=Integer, gpa=Float) 34 | @yorm.sync("students/{self.school}/{self.number}.yml") 35 | class Student: 36 | ... 37 | ``` 38 | 39 | 40 | -------------------------------------------------------------------------------- /docs/api/custom-types.md: -------------------------------------------------------------------------------- 1 | This documentation is a work in progress. Please help expand it. 2 | 3 | --- 4 | 5 | # Examples 6 | 7 | | Class | Python Value | YAML Data | 8 | | --- | --- | --- | 9 | | Number | `42.0` | `42` | 10 | | NullableNumber | `None` | `null` | 11 | -------------------------------------------------------------------------------- /docs/api/instance-mapping.md: -------------------------------------------------------------------------------- 1 | This documentation is a work in progress. Please help expand it. 2 | -------------------------------------------------------------------------------- /docs/api/utilities.md: -------------------------------------------------------------------------------- 1 | # Utility Functions 2 | 3 | YORM provides a set of utility functions to interact with mapped classes and instances in a manner similar to other ORMs. 4 | 5 | **Create** 6 | 7 | To create a new mapped object: 8 | 9 | ```python 10 | yorm.create(MyClass, *my_args, **my_kwargs) 11 | ``` 12 | 13 | If YORM is allow to overwrite an existing file during the mapping: 14 | 15 | ```python 16 | yorm.create(MyClass, ..., overwrite=True) 17 | ``` 18 | 19 | **Find** 20 | 21 | > Documentation coming soon... 22 | 23 | **Match** 24 | 25 | To yield all matching instances of a mapped class: 26 | 27 | ```python 28 | yorm.match(MyClass, **my_kwargs) 29 | ``` 30 | 31 | where `**my_kwargs` are zero or more keyword arguments to filter instances by. 32 | 33 | **Load** 34 | 35 | > Documentation coming soon... 36 | 37 | **Save** 38 | 39 | > Documentation coming soon... 40 | 41 | **Delete** 42 | 43 | > Documentation coming soon... 44 | 45 | # ORM Methods 46 | 47 | If you would like your class and its instances to behave more like a traditional object-relational mapping (ORM) model, use the provided mixin class: 48 | 49 | ```python 50 | import yorm 51 | 52 | class Student(yorm.ModelMixin): 53 | ... 54 | ``` 55 | 56 | which will add the following class methods: 57 | 58 | - `new` - object factory 59 | - `find` - return a single matching object 60 | - `match` - return all matching objects 61 | 62 | and instance methods: 63 | 64 | - `load` - update the object from its file 65 | - `save` - update the file from its object 66 | - `delete` - delete the object's file 67 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /examples/demo.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": { 7 | "collapsed": false 8 | }, 9 | "outputs": [], 10 | "source": [ 11 | "import logging\n", 12 | "logger = logging.getLogger()\n", 13 | "logger.setLevel(logging.DEBUG)" 14 | ] 15 | }, 16 | { 17 | "cell_type": "code", 18 | "execution_count": null, 19 | "metadata": { 20 | "collapsed": false 21 | }, 22 | "outputs": [], 23 | "source": [ 24 | "import yorm" 25 | ] 26 | }, 27 | { 28 | "cell_type": "code", 29 | "execution_count": null, 30 | "metadata": { 31 | "collapsed": false 32 | }, 33 | "outputs": [], 34 | "source": [ 35 | "class Student:\n", 36 | " \n", 37 | " def __init__(self, name, number, graduated=False):\n", 38 | " self.name = name\n", 39 | " self.number = number\n", 40 | " self.graduated = graduated\n", 41 | " \n", 42 | " def __repr__(self):\n", 43 | " return \"\".format(self.number)\n", 44 | " \n", 45 | " def graduate(self):\n", 46 | " self.graduated = True" 47 | ] 48 | }, 49 | { 50 | "cell_type": "code", 51 | "execution_count": null, 52 | "metadata": { 53 | "collapsed": false 54 | }, 55 | "outputs": [], 56 | "source": [ 57 | "@yorm.attr(name=yorm.types.String)\n", 58 | "@yorm.attr(graduated=yorm.types.Boolean)\n", 59 | "@yorm.sync(\"students/{n}.yml\", {'n': 'number'})\n", 60 | "class MappedStudent(Student):\n", 61 | " \n", 62 | " pass" 63 | ] 64 | }, 65 | { 66 | "cell_type": "code", 67 | "execution_count": null, 68 | "metadata": { 69 | "collapsed": false 70 | }, 71 | "outputs": [], 72 | "source": [ 73 | "s1 = MappedStudent(\"John Doe\", 123)" 74 | ] 75 | }, 76 | { 77 | "cell_type": "code", 78 | "execution_count": null, 79 | "metadata": { 80 | "collapsed": false 81 | }, 82 | "outputs": [], 83 | "source": [ 84 | "s2 = MappedStudent(\"Jane Doe\", 456, graduated=True)" 85 | ] 86 | } 87 | ], 88 | "metadata": { 89 | "kernelspec": { 90 | "display_name": "Python 3", 91 | "language": "python", 92 | "name": "python3" 93 | }, 94 | "language_info": { 95 | "codemirror_mode": { 96 | "name": "ipython", 97 | "version": 3 98 | }, 99 | "file_extension": ".py", 100 | "mimetype": "text/x-python", 101 | "name": "python", 102 | "nbconvert_exporter": "python", 103 | "pygments_lexer": "ipython3", 104 | "version": "3.4.3" 105 | } 106 | }, 107 | "nbformat": 4, 108 | "nbformat_minor": 0 109 | } 110 | -------------------------------------------------------------------------------- /examples/students/.gitignore: -------------------------------------------------------------------------------- 1 | *.yml 2 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: YORM 2 | site_description: Automatic object-YAML mapping for Python. 3 | site_author: Jace Browning 4 | repo_url: https://github.com/jacebrowning/yorm 5 | 6 | theme: readthedocs 7 | 8 | nav: 9 | - Home: index.md 10 | - API: 11 | - Instance Mapping: api/instance-mapping.md 12 | - Class Mapping: api/class-mapping.md 13 | - Builtin Types: api/builtin-types.md 14 | - Custom Types: api/custom-types.md 15 | - Utilities: api/utilities.md 16 | - About: 17 | - Release Notes: about/changelog.md 18 | - Contributing: about/contributing.md 19 | - License: about/license.md 20 | -------------------------------------------------------------------------------- /scent.py: -------------------------------------------------------------------------------- 1 | """Configuration file for sniffer.""" 2 | # pylint: disable=superfluous-parens,bad-continuation,unpacking-non-sequence 3 | 4 | import time 5 | import subprocess 6 | 7 | from sniffer.api import select_runnable, file_validator, runnable 8 | try: 9 | from pync import Notifier 10 | except ImportError: 11 | notify = None 12 | else: 13 | notify = Notifier.notify 14 | 15 | 16 | watch_paths = ["yorm", "tests"] 17 | 18 | 19 | class Options: 20 | group = int(time.time()) # unique per run 21 | show_coverage = False 22 | rerun_args = None 23 | 24 | targets = [ 25 | (('make', 'test-unit', 'DISABLE_COVERAGE=true'), "Unit Tests", True), 26 | (('make', 'test-all'), "Integration Tests", False), 27 | (('make', 'check'), "Static Analysis", True), 28 | (('make', 'docs'), None, True), 29 | ] 30 | 31 | 32 | @select_runnable('run_targets') 33 | @file_validator 34 | def python_files(filename): 35 | return filename.endswith('.py') 36 | 37 | 38 | @select_runnable('run_targets') 39 | @file_validator 40 | def html_files(filename): 41 | return filename.split('.')[-1] in ['html', 'css', 'js'] 42 | 43 | 44 | @runnable 45 | def run_targets(*args): 46 | """Run targets for Python.""" 47 | Options.show_coverage = 'coverage' in args 48 | 49 | count = 0 50 | for count, (command, title, retry) in enumerate(Options.targets, start=1): 51 | 52 | success = call(command, title, retry) 53 | if not success: 54 | message = "✅ " * (count - 1) + "❌" 55 | show_notification(message, title) 56 | 57 | return False 58 | 59 | message = "✅ " * count 60 | title = "All Targets" 61 | show_notification(message, title) 62 | show_coverage() 63 | 64 | return True 65 | 66 | 67 | def call(command, title, retry): 68 | """Run a command-line program and display the result.""" 69 | if Options.rerun_args: 70 | command, title, retry = Options.rerun_args 71 | Options.rerun_args = None 72 | success = call(command, title, retry) 73 | if not success: 74 | return False 75 | 76 | print("") 77 | print("$ %s" % ' '.join(command)) 78 | failure = subprocess.call(command) 79 | 80 | if failure and retry: 81 | Options.rerun_args = command, title, retry 82 | 83 | return not failure 84 | 85 | 86 | def show_notification(message, title): 87 | """Show a user notification.""" 88 | if notify and title: 89 | notify(message, title=title, group=Options.group) 90 | 91 | 92 | def show_coverage(): 93 | """Launch the coverage report.""" 94 | if Options.show_coverage: 95 | subprocess.call(['make', 'read-coverage']) 96 | 97 | Options.show_coverage = False 98 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | 3 | exe=1 4 | 5 | with-doctest=1 6 | 7 | with-coverage=1 8 | cover-package=yorm 9 | cover-erase=1 10 | cover-min-percentage=100 11 | 12 | verbosity=1 13 | logging-level=DEBUG 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | import setuptools 7 | 8 | 9 | PACKAGE_NAME = 'yorm' 10 | MINIMUM_PYTHON_VERSION = '3.3' 11 | 12 | 13 | def check_python_version(): 14 | """Exit when the Python version is too low.""" 15 | if sys.version < MINIMUM_PYTHON_VERSION: 16 | sys.exit("Python {0}+ is required.".format(MINIMUM_PYTHON_VERSION)) 17 | 18 | 19 | def read_package_variable(key, filename='__init__.py'): 20 | """Read the value of a variable from the package without importing.""" 21 | module_path = os.path.join(PACKAGE_NAME, filename) 22 | with open(module_path) as module: 23 | for line in module: 24 | parts = line.strip().split(' ', 2) 25 | if parts[:-1] == [key, '=']: 26 | return parts[-1].strip("'") 27 | sys.exit("'%s' not found in '%s'", key, module_path) 28 | 29 | 30 | def build_description(): 31 | """Build a description for the project from documentation files.""" 32 | try: 33 | readme = open("README.rst").read() 34 | changelog = open("CHANGELOG.rst").read() 35 | except IOError: 36 | return "" 37 | else: 38 | return readme + '\n' + changelog 39 | 40 | 41 | check_python_version() 42 | 43 | setuptools.setup( 44 | name=read_package_variable('__project__'), 45 | version=read_package_variable('__version__'), 46 | 47 | description="Automatic object-YAML mapping for Python.", 48 | url='https://github.com/jacebrowning/yorm', 49 | author='Jace Browning', 50 | author_email='jacebrowning@gmail.com', 51 | 52 | packages=setuptools.find_packages(), 53 | 54 | entry_points={'console_scripts': []}, 55 | 56 | long_description=build_description(), 57 | license='MIT', 58 | classifiers=[ 59 | 'Development Status :: 5 - Production/Stable', 60 | 'Intended Audience :: Developers', 61 | 'License :: OSI Approved :: MIT License', 62 | 'Natural Language :: English', 63 | 'Operating System :: OS Independent', 64 | 'Programming Language :: Python', 65 | 'Programming Language :: Python :: 3', 66 | 'Programming Language :: Python :: 3.5', 67 | 'Programming Language :: Python :: 3.6', 68 | 'Programming Language :: Python :: 3.7', 69 | 'Topic :: Database', 70 | 'Topic :: Software Development :: Libraries', 71 | 'Topic :: Software Development :: Version Control', 72 | 'Topic :: System :: Filesystems', 73 | 'Topic :: Text Editors :: Text Processing', 74 | ], 75 | 76 | install_requires=[ 77 | 'PyYAML >= 5.1, < 6', 78 | 'simplejson ~= 3.8', 79 | 'parse ~= 1.8.0', 80 | 'pathlib2 != 2.3.3', 81 | ], 82 | ) 83 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Integration tests for the package.""" 2 | 3 | from yorm.tests import strip, refresh_file_modification_times, log 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Integration tests configuration file.""" 2 | 3 | from yorm.tests.conftest import pytest_configure # pylint: disable=unused-import 4 | -------------------------------------------------------------------------------- /tests/files/my_key/config.yml: -------------------------------------------------------------------------------- 1 | key: my_key 2 | name: my_name 3 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | """Integration tests for the package.""" 2 | 3 | # pylint: disable=missing-docstring,no-self-use,no-member,misplaced-comparison-constant,attribute-defined-outside-init 4 | 5 | import yorm 6 | from yorm.types import Object, String, Integer, Float, Boolean 7 | from yorm.types import Markdown, Dictionary, List 8 | 9 | from . import strip, refresh_file_modification_times, log 10 | 11 | 12 | # CLASSES ##################################################################### 13 | 14 | 15 | class EmptyDictionary(Dictionary): 16 | """Sample dictionary container.""" 17 | 18 | 19 | @yorm.attr(all=Integer) 20 | class IntegerList(List): 21 | """Sample list container.""" 22 | 23 | 24 | class SampleStandard: 25 | """Sample class using standard attribute types.""" 26 | 27 | def __init__(self): 28 | # https://docs.python.org/3.4/library/json.html#json.JSONDecoder 29 | self.object = {} 30 | self.array = [] 31 | self.string = "" 32 | self.number_int = 0 33 | self.number_real = 0.0 34 | self.truthy = True 35 | self.falsey = False 36 | self.null = None 37 | 38 | def __repr__(self): 39 | return "".format(id(self)) 40 | 41 | 42 | @yorm.attr(array=IntegerList) 43 | @yorm.attr(falsey=Boolean) 44 | @yorm.attr(number_int=Integer) 45 | @yorm.attr(number_real=Float) 46 | @yorm.attr(object=EmptyDictionary) 47 | @yorm.attr(string=String) 48 | @yorm.attr(truthy=Boolean) 49 | @yorm.sync("tmp/{self.category}/{self.name}.yml") 50 | class SampleStandardDecorated: 51 | """Sample class using standard attribute types.""" 52 | 53 | def __init__(self, name, category='default'): 54 | self.name = name 55 | self.category = category 56 | # https://docs.python.org/3.4/library/json.html#json.JSONDecoder 57 | self.object = {} 58 | self.array = [] 59 | self.string = "" 60 | self.number_int = 0 61 | self.number_real = 0.0 62 | self.truthy = True 63 | self.falsey = False 64 | self.null = None 65 | 66 | def __repr__(self): 67 | return "".format(id(self)) 68 | 69 | 70 | @yorm.attr(label=String) 71 | @yorm.attr(status=Boolean) 72 | class StatusDictionary(Dictionary): 73 | """Sample dictionary container.""" 74 | 75 | 76 | @yorm.attr(all=StatusDictionary) 77 | class StatusDictionaryList(List): 78 | """Sample list container.""" 79 | 80 | 81 | class Level(String): 82 | """Sample custom attribute.""" 83 | 84 | @classmethod 85 | def to_data(cls, obj): 86 | value = cls.to_value(obj) 87 | count = value.split('.') 88 | if count == 0: 89 | return int(value) 90 | elif count == 1: 91 | return float(value) 92 | else: 93 | return value 94 | 95 | 96 | @yorm.sync("tmp/directory/{UUID}.yml", attrs={'level': Level}) 97 | class SampleCustomDecorated: 98 | """Sample class using custom attribute types.""" 99 | 100 | def __init__(self, name): 101 | self.name = name 102 | self.level = '1.0' 103 | 104 | def __repr__(self): 105 | return "".format(id(self)) 106 | 107 | 108 | @yorm.attr(string=String) 109 | @yorm.sync("tmp/sample.yml", auto_save=False) 110 | class SampleDecoratedAutoOff: 111 | """Sample class with automatic storage turned off.""" 112 | 113 | def __init__(self): 114 | self.string = "" 115 | 116 | def __repr__(self): 117 | return "".format(id(self)) 118 | 119 | 120 | @yorm.sync("tmp/sample.yml", auto_track=True) 121 | class SampleEmptyDecorated: 122 | """Sample class using standard attribute types.""" 123 | 124 | def __repr__(self): 125 | return "".format(id(self)) 126 | 127 | 128 | class SampleExtended: 129 | """Sample class using extended attribute types.""" 130 | 131 | def __init__(self): 132 | self.text = "" 133 | 134 | def __repr__(self): 135 | return "".format(id(self)) 136 | 137 | 138 | class SampleNested: 139 | """Sample class using nested attribute types.""" 140 | 141 | def __init__(self): 142 | self.count = 0 143 | self.results = [] 144 | 145 | def __repr__(self): 146 | return "".format(id(self)) 147 | 148 | # TESTS ####################################################################### 149 | 150 | 151 | class TestStandard: 152 | """Integration tests for standard attribute types.""" 153 | 154 | @yorm.attr(status=yorm.types.Boolean) 155 | class StatusDictionary(Dictionary): 156 | pass 157 | 158 | def test_decorator(self, tmpdir): 159 | """Verify standard attribute types dump/parse correctly (decorator).""" 160 | tmpdir.chdir() 161 | sample = SampleStandardDecorated('sample') 162 | assert "tmp/default/sample.yml" == sample.__mapper__.path 163 | 164 | log("Checking object default values...") 165 | assert {} == sample.object 166 | assert [] == sample.array 167 | assert "" == sample.string 168 | assert 0 == sample.number_int 169 | assert 0.0 == sample.number_real 170 | assert True is sample.truthy 171 | assert False is sample.falsey 172 | assert None is sample.null 173 | 174 | log("Changing object values...") 175 | sample.object = {'key2': 'value'} 176 | sample.array = [0, 1, 2] 177 | sample.string = "Hello, world!" 178 | sample.number_int = 42 179 | sample.number_real = 4.2 180 | sample.truthy = False 181 | sample.falsey = True 182 | 183 | log("Checking file contents...") 184 | assert strip(""" 185 | array: 186 | - 0 187 | - 1 188 | - 2 189 | falsey: true 190 | number_int: 42 191 | number_real: 4.2 192 | object: {} 193 | string: Hello, world! 194 | truthy: false 195 | """) == sample.__mapper__.text 196 | 197 | log("Changing file contents...") 198 | refresh_file_modification_times() 199 | sample.__mapper__.text = strip(""" 200 | array: [4, 5, 6] 201 | falsey: null 202 | number_int: 42 203 | number_real: '4.2' 204 | object: {'status': false} 205 | string: "abc" 206 | truthy: null 207 | """) 208 | 209 | log("Checking object values...") 210 | assert {'status': False} == sample.object 211 | assert [4, 5, 6] == sample.array 212 | assert "abc" == sample.string 213 | assert 42 == sample.number_int 214 | assert 4.2 == sample.number_real 215 | assert False is sample.truthy 216 | assert False is sample.falsey 217 | 218 | def test_function(self, tmpdir): 219 | """Verify standard attribute types dump/parse correctly (function).""" 220 | tmpdir.chdir() 221 | _sample = SampleStandard() 222 | attrs = {'object': self.StatusDictionary, 223 | 'array': IntegerList, 224 | 'string': String, 225 | 'number_int': Integer, 226 | 'number_real': Float, 227 | 'truthy': Boolean, 228 | 'falsey': Boolean} 229 | sample = yorm.sync(_sample, "tmp/directory/sample.yml", attrs) 230 | assert "tmp/directory/sample.yml" == sample.__mapper__.path 231 | 232 | # check defaults 233 | assert {'status': False} == sample.object 234 | assert [] == sample.array 235 | assert "" == sample.string 236 | assert 0 == sample.number_int 237 | assert 0.0 == sample.number_real 238 | assert True is sample.truthy 239 | assert False is sample.falsey 240 | assert None is sample.null 241 | 242 | # change object values 243 | sample.object = {'key': 'value'} 244 | sample.array = [1, 2, 3] 245 | sample.string = "Hello, world!" 246 | sample.number_int = 42 247 | sample.number_real = 4.2 248 | sample.truthy = None 249 | sample.falsey = 1 250 | 251 | # check file values 252 | assert strip(""" 253 | array: 254 | - 1 255 | - 2 256 | - 3 257 | falsey: true 258 | number_int: 42 259 | number_real: 4.2 260 | object: 261 | status: false 262 | string: Hello, world! 263 | truthy: false 264 | """) == sample.__mapper__.text 265 | 266 | def test_function_to_json(self, tmpdir): 267 | """Verify standard attribute types dump/parse correctly (function).""" 268 | tmpdir.chdir() 269 | _sample = SampleStandard() 270 | attrs = {'object': self.StatusDictionary, 271 | 'array': IntegerList, 272 | 'string': String, 273 | 'number_int': Integer, 274 | 'number_real': Float, 275 | 'truthy': Boolean, 276 | 'falsey': Boolean} 277 | sample = yorm.sync(_sample, "tmp/directory/sample.json", attrs) 278 | assert "tmp/directory/sample.json" == sample.__mapper__.path 279 | 280 | # check defaults 281 | assert {'status': False} == sample.object 282 | assert [] == sample.array 283 | assert "" == sample.string 284 | assert 0 == sample.number_int 285 | assert 0.0 == sample.number_real 286 | assert True is sample.truthy 287 | assert False is sample.falsey 288 | assert None is sample.null 289 | 290 | # change object values 291 | sample.object = {'key': 'value'} 292 | sample.array = [1, 2, 3] 293 | sample.string = "Hello, world!" 294 | sample.number_int = 42 295 | sample.number_real = 4.2 296 | sample.truthy = None 297 | sample.falsey = 1 298 | 299 | # check file values 300 | assert strip(""" 301 | { 302 | "array": [ 303 | 1, 304 | 2, 305 | 3 306 | ], 307 | "falsey": true, 308 | "number_int": 42, 309 | "number_real": 4.2, 310 | "object": { 311 | "status": false 312 | }, 313 | "string": "Hello, world!", 314 | "truthy": false 315 | } 316 | """, tabs=2, end='') == sample.__mapper__.text 317 | 318 | def test_auto_off(self, tmpdir): 319 | """Verify file updates are disabled with auto save off.""" 320 | tmpdir.chdir() 321 | sample = SampleDecoratedAutoOff() 322 | 323 | sample.string = "hello" 324 | assert "" == sample.__mapper__.text 325 | 326 | sample.__mapper__.auto_save = True 327 | sample.string = "world" 328 | 329 | assert strip(""" 330 | string: world 331 | """) == sample.__mapper__.text 332 | 333 | 334 | class TestContainers: 335 | """Integration tests for attribute containers.""" 336 | 337 | def test_nesting(self, tmpdir): 338 | """Verify standard attribute types can be nested.""" 339 | tmpdir.chdir() 340 | _sample = SampleNested() 341 | attrs = {'count': Integer, 342 | 'results': StatusDictionaryList} 343 | sample = yorm.sync(_sample, "tmp/sample.yml", attrs, auto_track=True) 344 | 345 | # check defaults 346 | assert 0 == sample.count 347 | assert [] == sample.results 348 | 349 | # change object values 350 | sample.count = 5 351 | sample.results = [{'status': False, 'label': "abc"}, 352 | {'status': None, 'label': None}, 353 | {'label': "def"}, 354 | {'status': True}, 355 | {}] 356 | 357 | # check file values 358 | assert strip(""" 359 | count: 5 360 | results: 361 | - label: abc 362 | status: false 363 | - label: '' 364 | status: false 365 | - label: def 366 | status: false 367 | - label: '' 368 | status: true 369 | - label: '' 370 | status: false 371 | """) == sample.__mapper__.text 372 | 373 | # change file values 374 | refresh_file_modification_times() 375 | sample.__mapper__.text = strip(""" 376 | count: 3 377 | other: 4.2 378 | results: 379 | - label: abc 380 | - label: null 381 | status: false 382 | - status: true 383 | """) 384 | 385 | # check object values 386 | assert 3 == sample.count 387 | assert 4.2 == sample.other 388 | assert [{'label': 'abc', 'status': False}, 389 | {'label': '', 'status': False}, 390 | {'label': '', 'status': True}] == sample.results 391 | 392 | def test_objects(self, tmpdir): 393 | """Verify containers are treated as objects when added.""" 394 | tmpdir.chdir() 395 | sample = SampleEmptyDecorated() 396 | 397 | # change file values 398 | refresh_file_modification_times() 399 | sample.__mapper__.text = strip(""" 400 | object: {'key': 'value'} 401 | array: [1, '2', '3.0'] 402 | """) 403 | 404 | # (a mapped attribute must be read first to trigger retrieving) 405 | sample.__mapper__.load() 406 | 407 | # check object values 408 | assert {'key': 'value'} == sample.object 409 | assert [1, '2', '3.0'] == sample.array 410 | 411 | # check object types 412 | assert Object == sample.__mapper__.attrs['object'] 413 | assert Object == sample.__mapper__.attrs['array'] 414 | 415 | 416 | class TestExtended: 417 | """Integration tests for extended attribute types.""" 418 | 419 | def test_function(self, tmpdir): 420 | """Verify extended attribute types dump/parse correctly.""" 421 | tmpdir.chdir() 422 | _sample = SampleExtended() 423 | attrs = {'text': Markdown} 424 | sample = yorm.sync(_sample, "tmp/directory/sample.yml", attrs) 425 | 426 | # check defaults 427 | assert "" == sample.text 428 | 429 | # change object values 430 | refresh_file_modification_times() 431 | sample.text = strip(""" 432 | This is the first sentence. This is the second sentence. 433 | This is the third sentence. 434 | """) 435 | 436 | # check file values 437 | assert strip(""" 438 | text: | 439 | This is the first sentence. 440 | This is the second sentence. 441 | This is the third sentence. 442 | """) == sample.__mapper__.text 443 | 444 | # change file values 445 | refresh_file_modification_times() 446 | sample.__mapper__.text = strip(""" 447 | text: | 448 | This is a 449 | sentence. 450 | """) 451 | 452 | # check object values 453 | assert "This is a sentence." == sample.text 454 | 455 | 456 | class TestCustom: 457 | """Integration tests for custom attribute types.""" 458 | 459 | def test_decorator(self, tmpdir): 460 | """Verify custom attribute types dump/parse correctly.""" 461 | tmpdir.chdir() 462 | sample = SampleCustomDecorated('sample') 463 | 464 | # check defaults 465 | assert '1.0' == sample.level 466 | 467 | # change values 468 | sample.level = '1.2.3' 469 | 470 | # check file values 471 | assert strip(""" 472 | level: 1.2.3 473 | """) == sample.__mapper__.text 474 | 475 | # change file values 476 | refresh_file_modification_times() 477 | sample.__mapper__.text = strip(""" 478 | level: 1 479 | """) 480 | 481 | # check object values 482 | assert '1' == sample.level 483 | -------------------------------------------------------------------------------- /tests/test_fake.py: -------------------------------------------------------------------------------- 1 | """Integration tests for the `yorm.settings.fake` option.""" 2 | 3 | # pylint: disable=missing-docstring,no-self-use,no-member,misplaced-comparison-constant 4 | 5 | import os 6 | from unittest.mock import patch 7 | 8 | import yorm 9 | 10 | from . import strip 11 | 12 | 13 | # CLASSES ##################################################################### 14 | 15 | 16 | @yorm.attr(value=yorm.types.standard.Integer) 17 | @yorm.sync("tmp/path/to/{self.name}.yml") 18 | class Sample: 19 | """Sample class for fake mapping.""" 20 | 21 | def __init__(self, name): 22 | self.name = name 23 | self.value = 0 24 | 25 | def __repr__(self): 26 | return "".format(id(self)) 27 | 28 | 29 | # TESTS ####################################################################### 30 | 31 | 32 | @patch('yorm.settings.fake', True) 33 | class TestFake: 34 | """Integration tests with `yorm.settings.fake` enabled.""" 35 | 36 | def test_no_file_create_when_fake(self, tmpdir): 37 | tmpdir.chdir() 38 | sample = Sample('sample') 39 | 40 | # ensure no file is created 41 | assert "tmp/path/to/sample.yml" == sample.__mapper__.path 42 | assert not os.path.exists(sample.__mapper__.path) 43 | 44 | # change object values 45 | sample.value = 42 46 | 47 | # check fake file 48 | assert strip(""" 49 | value: 42 50 | """) == sample.__mapper__.text 51 | 52 | # ensure no file is created 53 | assert not os.path.exists(sample.__mapper__.path) 54 | 55 | # change fake file 56 | sample.__mapper__.text = "value: 0\n" 57 | 58 | # check object values 59 | assert 0 == sample.value 60 | 61 | # ensure no file is created 62 | assert not os.path.exists(sample.__mapper__.path) 63 | 64 | def test_fake_changes_indicate_modified(self, tmpdir): 65 | tmpdir.chdir() 66 | sample = Sample('sample') 67 | 68 | assert False is sample.__mapper__.modified 69 | assert 0 == sample.value 70 | 71 | sample.__mapper__.text = "value: 42\n" 72 | 73 | assert True is sample.__mapper__.modified 74 | assert 42 == sample.value 75 | assert False is sample.__mapper__.modified 76 | 77 | 78 | class TestReal: 79 | """Integration tests with `yorm.settings.fake` disabled.""" 80 | 81 | def test_setting_text_updates_attributes(self, tmpdir): 82 | tmpdir.chdir() 83 | sample = Sample('sample') 84 | 85 | sample.__mapper__.text = "value: 42" 86 | 87 | assert 42 == sample.value 88 | 89 | def test_setting_attributes_update_text(self, tmpdir): 90 | tmpdir.chdir() 91 | sample = Sample('sample') 92 | 93 | sample.value = 42 94 | 95 | assert strip(""" 96 | value: 42 97 | """) == sample.__mapper__.text 98 | -------------------------------------------------------------------------------- /tests/test_files.py: -------------------------------------------------------------------------------- 1 | """Integration tests for file IO.""" 2 | 3 | # pylint: disable=missing-docstring,no-self-use,no-member,misplaced-comparison-constant 4 | 5 | import pytest 6 | 7 | import yorm 8 | from yorm.types import Integer, String, Float, Boolean, Dictionary, List 9 | 10 | from . import refresh_file_modification_times, log 11 | 12 | 13 | # CLASSES ##################################################################### 14 | 15 | 16 | class EmptyDictionary(Dictionary): 17 | """Sample dictionary container.""" 18 | 19 | 20 | @yorm.attr(all=Integer) 21 | class IntegerList(List): 22 | """Sample list container.""" 23 | 24 | 25 | @yorm.attr(array=IntegerList) 26 | @yorm.attr(false=Boolean) 27 | @yorm.attr(number_int=Integer) 28 | @yorm.attr(number_real=Float) 29 | @yorm.attr(object=EmptyDictionary) 30 | @yorm.attr(string=String) 31 | @yorm.attr(true=Boolean) 32 | @yorm.sync("tmp/path/to/{self.category}/{self.name}.yml") 33 | class SampleStandardDecorated: 34 | """Sample class using standard attribute types.""" 35 | 36 | def __init__(self, name, category='default'): 37 | # pylint: disable=duplicate-code 38 | self.name = name 39 | self.category = category 40 | # https://docs.python.org/3.4/library/json.html#json.JSONDecoder 41 | self.object = {} 42 | self.array = [] 43 | # pylint: disable=duplicate-code 44 | self.string = "" 45 | self.number_int = 0 46 | self.number_real = 0.0 47 | # pylint: disable=duplicate-code 48 | self.true = True 49 | self.false = False 50 | self.null = None 51 | 52 | def __repr__(self): 53 | return "".format(id(self)) 54 | 55 | 56 | # TESTS ####################################################################### 57 | 58 | 59 | class TestCreate: 60 | """Integration tests for creating mapped classes.""" 61 | 62 | def test_load_from_existing(self, tmpdir): 63 | """Verify attributes are updated from an existing file.""" 64 | tmpdir.chdir() 65 | sample = SampleStandardDecorated('sample') 66 | sample2 = SampleStandardDecorated('sample') 67 | assert sample2.__mapper__.path == sample.__mapper__.path 68 | 69 | refresh_file_modification_times() 70 | 71 | log("Changing values in object 1...") 72 | sample.array = [0, 1, 2] 73 | sample.string = "Hello, world!" 74 | sample.number_int = 42 75 | sample.number_real = 4.2 76 | sample.true = True 77 | sample.false = False 78 | 79 | log("Reading changed values in object 2...") 80 | assert [0, 1, 2] == sample2.array 81 | assert "Hello, world!" == sample2.string 82 | assert 42 == sample2.number_int 83 | assert 4.2 == sample2.number_real 84 | assert True is sample2.true 85 | assert False is sample2.false 86 | 87 | 88 | class TestDelete: 89 | """Integration tests for deleting files.""" 90 | 91 | def test_read(self, tmpdir): 92 | """Verify a deleted file cannot be read from.""" 93 | tmpdir.chdir() 94 | sample = SampleStandardDecorated('sample') 95 | sample.__mapper__.delete() 96 | 97 | with pytest.raises(FileNotFoundError): 98 | print(sample.string) 99 | 100 | with pytest.raises(FileNotFoundError): 101 | sample.string = "def456" 102 | 103 | def test_write(self, tmpdir): 104 | """Verify a deleted file cannot be written to.""" 105 | tmpdir.chdir() 106 | sample = SampleStandardDecorated('sample') 107 | sample.__mapper__.delete() 108 | 109 | with pytest.raises(FileNotFoundError): 110 | sample.string = "def456" 111 | 112 | def test_multiple(self, tmpdir): 113 | """Verify a deleted file can be deleted again.""" 114 | tmpdir.chdir() 115 | sample = SampleStandardDecorated('sample') 116 | sample.__mapper__.delete() 117 | sample.__mapper__.delete() 118 | 119 | 120 | class TestUpdate: 121 | """Integration tests for updating files/object.""" 122 | 123 | def test_automatic_save_after_first_modification(self, tmpdir): 124 | tmpdir.chdir() 125 | sample = SampleStandardDecorated('sample') 126 | assert "number_int: 0\n" in sample.__mapper__.text 127 | 128 | sample.number_int = 42 129 | assert "number_int: 42\n" in sample.__mapper__.text 130 | 131 | sample.__mapper__.text = "number_int: true\n" 132 | assert 1 is sample.number_int 133 | assert "number_int: 1\n" in sample.__mapper__.text 134 | 135 | def test_automatic_save_after_first_modification_on_list(self, tmpdir): 136 | tmpdir.chdir() 137 | sample = SampleStandardDecorated('sample') 138 | assert "array:\n-\n" in sample.__mapper__.text 139 | 140 | sample.array.append(42) 141 | assert "array:\n- 42\n" in sample.__mapper__.text 142 | 143 | sample.__mapper__.text = "array: [true]\n" 144 | iter(sample.array) 145 | assert "array:\n- 1\n" in sample.__mapper__.text 146 | -------------------------------------------------------------------------------- /tests/test_imports.py: -------------------------------------------------------------------------------- 1 | """Integration tests for the package namespace.""" 2 | 3 | # pylint: disable=missing-docstring,no-self-use,unused-variable,unused-import 4 | 5 | 6 | def test_top(): 7 | import yorm 8 | assert yorm.bases 9 | assert yorm.types.Integer 10 | assert yorm.types.extended.Markdown 11 | 12 | 13 | def test_from_top_constants(): 14 | from yorm import UUID 15 | 16 | 17 | def test_from_top_clases(): 18 | from yorm import Mappable 19 | from yorm import Converter, Container 20 | from yorm import ModelMixin 21 | 22 | 23 | def test_from_top_decorators(): 24 | from yorm import sync 25 | from yorm import sync_instances 26 | from yorm import sync_object 27 | from yorm import attr 28 | 29 | 30 | def test_from_top_utilities(): 31 | from yorm import create 32 | from yorm import find 33 | from yorm import match 34 | from yorm import load 35 | from yorm import save 36 | from yorm import delete 37 | 38 | 39 | def test_from_nested(): 40 | from yorm.types import Integer, Number 41 | from yorm.types.standard import String 42 | from yorm.types.extended import Markdown 43 | from yorm.types.containers import List 44 | -------------------------------------------------------------------------------- /tests/test_list_shortcut.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=redefined-outer-name,expression-not-assigned,attribute-defined-outside-init,no-member 2 | 3 | from expecter import expect 4 | 5 | import yorm 6 | from yorm.types import List, Float 7 | 8 | from . import strip 9 | 10 | 11 | @yorm.attr(things=List.of_type(Float)) 12 | @yorm.sync("tmp/example.yml") 13 | class Example: 14 | """An example class mapping a list using the shortened syntax.""" 15 | 16 | 17 | def test_list_mapping_using_shortened_syntax(): 18 | obj = Example() 19 | obj.things = [1, 2.0, "3"] 20 | 21 | expect(obj.__mapper__.text) == strip(""" 22 | things: 23 | - 1.0 24 | - 2.0 25 | - 3.0 26 | """) 27 | -------------------------------------------------------------------------------- /tests/test_mapped_signature.py: -------------------------------------------------------------------------------- 1 | """Integration tests to ensure a mapped object's signature is unchanged.""" 2 | 3 | # pylint: disable=redefined-outer-name,unused-variable,expression-not-assigned 4 | 5 | import pytest 6 | from expecter import expect 7 | 8 | from yorm import decorators 9 | 10 | 11 | class SampleWithMagicMethods: 12 | 13 | def __init__(self): 14 | self.values = [] 15 | 16 | def __setattr__(self, name, value): 17 | if name == 'foobar': 18 | self.values.append(('__setattr__', name, value)) 19 | super().__setattr__(name, value) 20 | 21 | 22 | @pytest.yield_fixture 23 | def mapped(): 24 | import yorm 25 | yorm.settings.fake = True 26 | yield decorators.sync(SampleWithMagicMethods(), "sample.yml") 27 | yorm.settings.fake = False 28 | 29 | 30 | @pytest.fixture 31 | def unmapped(): 32 | return SampleWithMagicMethods() 33 | 34 | 35 | def fuzzy_repr(obj): 36 | return repr(obj).split(" object at ")[0] + ">" 37 | 38 | 39 | def describe_mapped_object(): 40 | 41 | def it_retains_representation(mapped, unmapped): 42 | expect(fuzzy_repr(mapped)) == fuzzy_repr(unmapped) 43 | 44 | def it_retains_docstring(mapped, unmapped): 45 | expect(mapped.__doc__) == unmapped.__doc__ 46 | 47 | def it_retains_class(mapped, unmapped): 48 | expect(mapped.__class__) == unmapped.__class__ 49 | 50 | def it_retains_magic_methods(mapped): 51 | setattr(mapped, 'foobar', 42) 52 | expect(mapped.values) == [('__setattr__', 'foobar', 42)] 53 | 54 | def it_does_not_affect_unmapped_objects(mapped, unmapped): 55 | expect(hasattr(mapped, '__mapper__')) is True 56 | expect(hasattr(unmapped, '__mapper__')) is False 57 | expect(hasattr(mapped.__setattr__, '_modified_by_yorm')) is True 58 | expect(hasattr(unmapped.__setattr__, '_modified_by_yorm')) is False 59 | -------------------------------------------------------------------------------- /tests/test_nested_attributes.py: -------------------------------------------------------------------------------- 1 | """Integration tests for nested attributes.""" 2 | 3 | # pylint: disable=missing-docstring,no-self-use,attribute-defined-outside-init,no-member 4 | # pylint: disable=unused-variable,misplaced-comparison-constant 5 | 6 | from unittest.mock import patch 7 | 8 | import pytest 9 | from expecter import expect 10 | 11 | import yorm 12 | 13 | from . import strip, log 14 | 15 | 16 | @yorm.attr(status=yorm.types.Boolean) 17 | @yorm.attr(checked=yorm.types.Integer) 18 | class StatusDictionary(yorm.types.Dictionary): 19 | 20 | def __init__(self, status, checked): 21 | self.status = status 22 | self.checked = checked 23 | if self.checked == 42: 24 | raise RuntimeError 25 | 26 | 27 | @yorm.attr(all=yorm.types.Float) 28 | class NestedList3(yorm.types.List): 29 | 30 | def __repr__(self): 31 | return "".format((id(self))) 32 | 33 | 34 | @yorm.attr(nested_list_3=NestedList3) 35 | @yorm.attr(number=yorm.types.Float) 36 | class NestedDictionary3(yorm.types.AttributeDictionary): 37 | 38 | def __init__(self): 39 | super().__init__() 40 | self.number = 0 41 | self.nested_list_3 = [] 42 | 43 | def __repr__(self): 44 | print(self.number) # trigger a potential recursion issue 45 | return "".format((id(self))) 46 | 47 | 48 | @yorm.attr(all=yorm.types.Float) 49 | class NestedList2(yorm.types.List): 50 | 51 | def __repr__(self): 52 | return "".format((id(self))) 53 | 54 | 55 | @yorm.attr(nested_dict_3=NestedDictionary3) 56 | @yorm.attr(number=yorm.types.Float) 57 | class NestedDictionary2(yorm.types.AttributeDictionary): 58 | 59 | def __init__(self): 60 | super().__init__() 61 | self.number = 0 62 | 63 | def __repr__(self): 64 | print(self.number) # trigger a potential recursion issue 65 | return "".format((id(self))) 66 | 67 | 68 | @yorm.attr(nested_list_2=NestedList2) 69 | @yorm.attr(number=yorm.types.Float) 70 | class NestedDictionary(yorm.types.AttributeDictionary): 71 | 72 | def __init__(self): 73 | super().__init__() 74 | self.number = 0 75 | self.nested_list_2 = [] 76 | 77 | def __repr__(self): 78 | return "".format((id(self))) 79 | 80 | 81 | @yorm.attr(all=NestedDictionary2) 82 | class NestedList(yorm.types.List): 83 | 84 | def __repr__(self): 85 | return "".format((id(self))) 86 | 87 | 88 | @yorm.attr(nested_dict=NestedDictionary) 89 | @yorm.attr(nested_list=NestedList) 90 | @yorm.sync("sample.yml") 91 | class Top: 92 | 93 | def __init__(self): 94 | self.nested_list = [] 95 | self.nested_dict = {} 96 | 97 | def __repr__(self): 98 | return "".format((id(self))) 99 | 100 | 101 | @patch('yorm.settings.fake', True) 102 | class TestNestedOnce: 103 | 104 | def test_append_triggers_save(self): 105 | top = Top() 106 | log("Appending dictionary to list...") 107 | top.nested_list.append({'number': 1}) 108 | log("Checking text...") 109 | assert strip(""" 110 | nested_dict: 111 | nested_list_2: 112 | - 113 | number: 0.0 114 | nested_list: 115 | - nested_dict_3: 116 | nested_list_3: 117 | - 118 | number: 0.0 119 | number: 1.0 120 | """) == top.__mapper__.text 121 | 122 | def test_set_by_index_triggers_save(self): 123 | top = Top() 124 | top.nested_list = [{'number': 1.5}] 125 | assert strip(""" 126 | nested_dict: 127 | nested_list_2: 128 | - 129 | number: 0.0 130 | nested_list: 131 | - nested_dict_3: 132 | nested_list_3: 133 | - 134 | number: 0.0 135 | number: 1.5 136 | """) == top.__mapper__.text 137 | top.nested_list[0] = {'number': 1.6} 138 | assert strip(""" 139 | nested_dict: 140 | nested_list_2: 141 | - 142 | number: 0.0 143 | nested_list: 144 | - nested_dict_3: 145 | nested_list_3: 146 | - 147 | number: 0.0 148 | number: 1.6 149 | """) == top.__mapper__.text 150 | 151 | def test_get_by_index_triggers_load(self): 152 | top = Top() 153 | top.__mapper__.text = strip(""" 154 | nested_list: 155 | - number: 1.7 156 | """) 157 | assert 1.7 == top.nested_list[0].number 158 | 159 | def test_delete_index_triggers_save(self): 160 | top = Top() 161 | top.nested_list = [{'number': 1.8}, {'number': 1.9}] 162 | assert strip(""" 163 | nested_dict: 164 | nested_list_2: 165 | - 166 | number: 0.0 167 | nested_list: 168 | - nested_dict_3: 169 | nested_list_3: 170 | - 171 | number: 0.0 172 | number: 1.8 173 | - nested_dict_3: 174 | nested_list_3: 175 | - 176 | number: 0.0 177 | number: 1.9 178 | """) == top.__mapper__.text 179 | del top.nested_list[0] 180 | assert strip(""" 181 | nested_dict: 182 | nested_list_2: 183 | - 184 | number: 0.0 185 | nested_list: 186 | - nested_dict_3: 187 | nested_list_3: 188 | - 189 | number: 0.0 190 | number: 1.9 191 | """) == top.__mapper__.text 192 | 193 | def test_set_dict_as_attribute_triggers_save(self): 194 | top = Top() 195 | top.nested_dict.number = 2 196 | assert strip(""" 197 | nested_dict: 198 | nested_list_2: 199 | - 200 | number: 2.0 201 | nested_list: 202 | - 203 | """) == top.__mapper__.text 204 | 205 | 206 | @patch('yorm.settings.fake', True) 207 | class TestNestedTwice: 208 | 209 | def test_nested_list_item_value_change_triggers_save(self): 210 | top = Top() 211 | top.nested_list = [{'number': 3}] 212 | assert strip(""" 213 | nested_dict: 214 | nested_list_2: 215 | - 216 | number: 0.0 217 | nested_list: 218 | - nested_dict_3: 219 | nested_list_3: 220 | - 221 | number: 0.0 222 | number: 3.0 223 | """) == top.__mapper__.text 224 | top.nested_list[0].number = 4 225 | assert strip(""" 226 | nested_dict: 227 | nested_list_2: 228 | - 229 | number: 0.0 230 | nested_list: 231 | - nested_dict_3: 232 | nested_list_3: 233 | - 234 | number: 0.0 235 | number: 4.0 236 | """) == top.__mapper__.text 237 | 238 | def test_nested_dict_item_value_change_triggers_save(self): 239 | top = Top() 240 | top.nested_dict = {'nested_list_2': [5]} 241 | assert strip(""" 242 | nested_dict: 243 | nested_list_2: 244 | - 5.0 245 | number: 0.0 246 | nested_list: 247 | - 248 | """) == top.__mapper__.text 249 | top.nested_dict.nested_list_2.append(6) 250 | assert strip(""" 251 | nested_dict: 252 | nested_list_2: 253 | - 5.0 254 | - 6.0 255 | number: 0.0 256 | nested_list: 257 | - 258 | """) == top.__mapper__.text 259 | 260 | def test_dict_in_list_value_change_triggers_save(self): 261 | top = Top() 262 | log("Appending to list...") 263 | top.nested_list.append('foobar') 264 | log("Setting nested value...") 265 | top.nested_list[0].nested_dict_3.number = 8 266 | assert strip(""" 267 | nested_dict: 268 | nested_list_2: 269 | - 270 | number: 0.0 271 | nested_list: 272 | - nested_dict_3: 273 | nested_list_3: 274 | - 275 | number: 8.0 276 | number: 0.0 277 | """) == top.__mapper__.text 278 | 279 | def test_list_in_dict_append_triggers_save(self): 280 | top = Top() 281 | top.nested_list.append('foobar') 282 | top.nested_list.append('foobar') 283 | for nested_dict_2 in top.nested_list: 284 | nested_dict_2.number = 9 285 | nested_dict_2.nested_dict_3.nested_list_3.append(10) 286 | assert strip(""" 287 | nested_dict: 288 | nested_list_2: 289 | - 290 | number: 0.0 291 | nested_list: 292 | - nested_dict_3: 293 | nested_list_3: 294 | - 10.0 295 | number: 0.0 296 | number: 9.0 297 | - nested_dict_3: 298 | nested_list_3: 299 | - 10.0 300 | number: 0.0 301 | number: 9.0 302 | """) == top.__mapper__.text 303 | 304 | 305 | def describe_aliases(): 306 | 307 | @pytest.fixture 308 | def sample(tmpdir): 309 | cls = type('Sample', (), {}) 310 | path = str(tmpdir.join("sample.yml")) 311 | attrs = dict(var4=NestedList3, var5=StatusDictionary) 312 | return yorm.sync(cls(), path, attrs) 313 | 314 | def _log_ref(name, var, ref): 315 | log("%s: %r", name, var) 316 | log("%s_ref: %r", name, ref) 317 | log("%s ID: %s", name, id(var)) 318 | log("%s_ref ID: %s", name, id(ref)) 319 | assert id(ref) == id(var) 320 | assert ref == var 321 | 322 | def test_alias_list(sample): 323 | var4_ref = sample.var4 324 | _log_ref('var4', sample.var4, var4_ref) 325 | assert [] == sample.var4 326 | 327 | log("Appending 42 to var4_ref...") 328 | var4_ref.append(42) 329 | _log_ref('var4', sample.var4, var4_ref) 330 | assert [42] == sample.var4 331 | 332 | log("Appending 2015 to var4_ref...") 333 | var4_ref.append(2015) 334 | assert [42, 2015] == sample.var4 335 | 336 | def test_alias_dict(sample): 337 | var5_ref = sample.var5 338 | _log_ref('var5', sample.var5, var5_ref) 339 | assert {'status': False, 'checked': 0} == sample.var5 340 | 341 | log("Setting status=True in var5_ref...") 342 | var5_ref['status'] = True 343 | _log_ref('var5', sample.var5, var5_ref) 344 | assert {'status': True, 'checked': 0} == sample.var5 345 | 346 | log("Setting status=False in var5_ref...") 347 | var5_ref['status'] = False 348 | _log_ref('var5', sample.var5, var5_ref) 349 | assert {'status': False, 'checked': 0} == sample.var5 350 | 351 | def test_alias_dict_in_list(): 352 | top = Top() 353 | top.nested_list.append('foobar') 354 | ref1 = top.nested_list[0] 355 | ref2 = top.nested_list[0].nested_dict_3 356 | ref3 = top.nested_list[0].nested_dict_3.nested_list_3 357 | assert id(ref1) == id(top.nested_list[0]) 358 | assert id(ref2) == id(top.nested_list[0].nested_dict_3) 359 | assert id(ref3) == id(top.nested_list[0].nested_dict_3.nested_list_3) 360 | 361 | def test_alias_list_in_dict(): 362 | top = Top() 363 | log("Updating nested attribute...") 364 | top.nested_dict.number = 1 365 | log("Grabbing refs...") 366 | ref1 = top.nested_dict 367 | ref2 = top.nested_dict.nested_list_2 368 | assert id(ref1) == id(top.nested_dict) 369 | assert id(ref2) == id(top.nested_dict.nested_list_2) 370 | 371 | def test_custom_init_is_invoked(sample): 372 | sample.__mapper__.text = "var5:\n checked: 42" 373 | with expect.raises(RuntimeError): 374 | print(sample.var5) 375 | -------------------------------------------------------------------------------- /tests/test_ordering.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=redefined-outer-name,expression-not-assigned,attribute-defined-outside-init,no-member 2 | 3 | from expecter import expect 4 | 5 | import yorm 6 | 7 | from . import strip 8 | 9 | 10 | @yorm.attr(status=yorm.types.Boolean) 11 | @yorm.attr(label=yorm.types.String) 12 | class StatusDictionary(yorm.types.Dictionary): 13 | """Sample dictionary converter with ordered attributes.""" 14 | 15 | 16 | @yorm.attr(string=yorm.types.String) 17 | @yorm.attr(number_int=yorm.types.Integer) 18 | @yorm.attr(dictionary=StatusDictionary) 19 | @yorm.attr(number_real=yorm.types.Float) 20 | @yorm.attr(truthy=yorm.types.Boolean) 21 | @yorm.attr(falsey=yorm.types.Boolean) 22 | @yorm.sync("sample.yml") 23 | class Sample: 24 | """Sample class with ordered attributes.""" 25 | 26 | 27 | def test_attribute_order_is_maintained(tmpdir): 28 | tmpdir.chdir() 29 | sample = Sample() 30 | sample.string = "Hello, world!" 31 | sample.number_int = 42 32 | sample.number_real = 4.2 33 | # pylint: disable=duplicate-code 34 | sample.truthy = False 35 | sample.falsey = True 36 | sample.dictionary['status'] = 1 37 | 38 | expect(sample.__mapper__.text) == strip(""" 39 | string: Hello, world! 40 | number_int: 42 41 | dictionary: 42 | status: true 43 | label: '' 44 | number_real: 4.2 45 | truthy: false 46 | falsey: true 47 | """) 48 | 49 | 50 | def test_existing_files_are_reorderd(tmpdir): 51 | tmpdir.chdir() 52 | with open("sample.yml", 'w') as stream: 53 | stream.write(strip(""" 54 | falsey: 1 55 | number_int: 2 56 | number_real: 3 57 | string: 4 58 | truthy: 5 59 | dictionary: {label: foo} 60 | """)) 61 | sample = Sample() 62 | sample.falsey = 0 63 | 64 | expect(sample.__mapper__.text) == strip(""" 65 | string: 4 66 | number_int: 2 67 | dictionary: 68 | status: false 69 | label: foo 70 | number_real: 3.0 71 | truthy: true 72 | falsey: false 73 | """) 74 | -------------------------------------------------------------------------------- /tests/test_persistence_models.py: -------------------------------------------------------------------------------- 1 | """Integration tests using YORM as a persistence model.""" 2 | 3 | # pylint: disable=missing-docstring,no-self-use,misplaced-comparison-constant 4 | 5 | import os 6 | 7 | from expecter import expect 8 | 9 | import yorm 10 | from yorm.types import String 11 | 12 | 13 | # CLASSES ##################################################################### 14 | 15 | 16 | class Config: 17 | """Domain model.""" 18 | 19 | def __init__(self, key, name=None, root=None): 20 | self.key = key 21 | self.name = name or "" 22 | self.root = root or "" 23 | 24 | 25 | @yorm.attr(key=String) 26 | @yorm.attr(name=String) 27 | @yorm.sync("{self.root}/{self.key}/config.yml", 28 | auto_create=False, auto_save=False) 29 | class ConfigModel: 30 | """Persistence model.""" 31 | 32 | def __init__(self, key, root): 33 | self.key = key 34 | self.root = root 35 | print(self.key) 36 | self.unmapped = 0 37 | 38 | @staticmethod 39 | def pm_to_dm(model): 40 | config = Config(model.key) 41 | config.name = model.name 42 | config.root = model.root 43 | return config 44 | 45 | 46 | class ConfigStore: 47 | 48 | def __init__(self, root): 49 | self.root = root 50 | 51 | def read(self, key): 52 | return yorm.find(ConfigModel, self.root, key) 53 | 54 | 55 | # TESTS ####################################################################### 56 | 57 | 58 | class TestPersistanceMapping: # pylint: disable=no-member 59 | 60 | root = os.path.join(os.path.dirname(__file__), 'files') 61 | 62 | def test_load_pm(self): 63 | model = ConfigModel('my_key', self.root) 64 | 65 | print(model.__dict__) 66 | assert model.key == "my_key" 67 | assert model.root == self.root 68 | assert model.name == "my_name" 69 | 70 | def test_create_dm_from_pm(self): 71 | model = ConfigModel('my_key', self.root) 72 | config = ConfigModel.pm_to_dm(model) 73 | 74 | print(config.__dict__) 75 | assert config.key == "my_key" 76 | assert config.root == self.root 77 | assert config.name == "my_name" 78 | 79 | def test_nonmapped_attribute_is_kept(self): 80 | model = ConfigModel('my_key', self.root) 81 | model.unmapped = 42 82 | assert 42 == model.unmapped 83 | 84 | def test_missing_files_are_handled(self): 85 | model = ConfigModel('my_key_manual', self.root) 86 | 87 | with expect.raises(yorm.exceptions.MissingFileError): 88 | print(model.name) 89 | 90 | 91 | class TestStore: 92 | 93 | def test_read_missing(self, tmpdir): 94 | store = ConfigStore(str(tmpdir)) 95 | assert None is store.read('unknown') 96 | -------------------------------------------------------------------------------- /yorm/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for YORM.""" 2 | 3 | from . import bases, types 4 | from .common import UUID 5 | from .decorators import sync, sync_object, sync_instances, attr 6 | from .utilities import create, find, match, load, save, delete 7 | from .bases import Container, Converter, Mappable 8 | from .mixins import ModelMixin 9 | 10 | __project__ = 'YORM' 11 | __version__ = '1.6.2' 12 | -------------------------------------------------------------------------------- /yorm/bases/__init__.py: -------------------------------------------------------------------------------- 1 | """Base classes for mapping and conversion.""" 2 | 3 | from .mappable import Mappable 4 | from .converter import Converter, Container 5 | -------------------------------------------------------------------------------- /yorm/bases/converter.py: -------------------------------------------------------------------------------- 1 | """Converter classes.""" 2 | 3 | from abc import ABCMeta, abstractclassmethod, abstractmethod 4 | import logging 5 | 6 | from .. import common 7 | from . import Mappable 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | class Converter(metaclass=ABCMeta): 13 | """Base class for attribute converters.""" 14 | 15 | @abstractclassmethod 16 | def create_default(cls): 17 | """Create a default value for an attribute.""" 18 | raise NotImplementedError(common.OVERRIDE_MESSAGE) 19 | 20 | @abstractclassmethod 21 | def to_value(cls, data): 22 | """Convert parsed data to an attribute's value.""" 23 | raise NotImplementedError(common.OVERRIDE_MESSAGE) 24 | 25 | @abstractclassmethod 26 | def to_data(cls, value): 27 | """Convert an attribute to data optimized for dumping.""" 28 | raise NotImplementedError(common.OVERRIDE_MESSAGE) 29 | 30 | 31 | class Container(Mappable, Converter, metaclass=ABCMeta): 32 | """Base class for mutable attribute converters.""" 33 | 34 | @classmethod 35 | def create_default(cls): 36 | return cls.__new__(cls) 37 | 38 | @classmethod 39 | def to_value(cls, data): 40 | value = cls.create_default() 41 | value.update_value(data, auto_track=True) 42 | return value 43 | 44 | @abstractmethod 45 | def update_value(self, data, *, auto_track): # pragma: no cover (abstract method) 46 | """Update the attribute's value from parsed data.""" 47 | raise NotImplementedError(common.OVERRIDE_MESSAGE) 48 | 49 | def format_data(self): 50 | """Format the attribute to data optimized for dumping.""" 51 | return self.to_data(self) 52 | -------------------------------------------------------------------------------- /yorm/bases/mappable.py: -------------------------------------------------------------------------------- 1 | """Base classes for mapping.""" 2 | 3 | import abc 4 | import functools 5 | import logging 6 | 7 | from .. import common 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | def load_before(method): 13 | """Decorate methods that should load before call.""" 14 | 15 | if getattr(method, '_load_before', False): 16 | return method 17 | 18 | @functools.wraps(method) 19 | def wrapped(self, *args, **kwargs): 20 | __tracebackhide__ = True # pylint: disable=unused-variable 21 | 22 | if not _private_call(method, args): 23 | mapper = common.get_mapper(self) 24 | if mapper and mapper.modified: 25 | log.debug("Loading before call: %s", method.__name__) 26 | mapper.load() 27 | if mapper.auto_save_after_load: 28 | mapper.save() 29 | mapper.modified = False 30 | 31 | return method(self, *args, **kwargs) 32 | 33 | setattr(wrapped, '_load_before', True) 34 | 35 | return wrapped 36 | 37 | 38 | def save_after(method): 39 | """Decorate methods that should save after call.""" 40 | 41 | if getattr(method, '_save_after', False): 42 | return method 43 | 44 | @functools.wraps(method) 45 | def wrapped(self, *args, **kwargs): 46 | __tracebackhide__ = True # pylint: disable=unused-variable 47 | 48 | result = method(self, *args, **kwargs) 49 | 50 | if not _private_call(method, args): 51 | mapper = common.get_mapper(self) 52 | if mapper and mapper.auto_save: 53 | log.debug("Saving after call: %s", method.__name__) 54 | mapper.save() 55 | 56 | return result 57 | 58 | setattr(wrapped, '_save_after', True) 59 | 60 | return wrapped 61 | 62 | 63 | def _private_call(method, args, prefix='_'): 64 | """Determine if a call's first argument is a private variable name.""" 65 | if method.__name__ in ('__getattribute__', '__setattr__'): 66 | assert isinstance(args[0], str) 67 | return args[0].startswith(prefix) 68 | else: 69 | return False 70 | 71 | 72 | class Mappable(metaclass=abc.ABCMeta): 73 | """Base class for objects with attributes mapped to file.""" 74 | 75 | # pylint: disable=no-member 76 | 77 | @load_before 78 | def __getattribute__(self, name): 79 | return object.__getattribute__(self, name) 80 | 81 | @save_after 82 | def __setattr__(self, name, value): 83 | super().__setattr__(name, value) 84 | 85 | @load_before 86 | def __iter__(self): 87 | return super().__iter__() 88 | 89 | @load_before 90 | def __getitem__(self, key): 91 | return super().__getitem__(key) 92 | 93 | @save_after 94 | def __setitem__(self, key, value): 95 | super().__setitem__(key, value) 96 | 97 | @save_after 98 | def __delitem__(self, key): 99 | super().__delitem__(key) 100 | 101 | @save_after 102 | def append(self, *args, **kwargs): 103 | super().append(*args, **kwargs) 104 | 105 | @save_after 106 | def extend(self, *args, **kwargs): 107 | super().extend(*args, **kwargs) 108 | 109 | @save_after 110 | def insert(self, *args, **kwargs): 111 | super().insert(*args, **kwargs) 112 | 113 | @save_after 114 | def remove(self, *args, **kwargs): 115 | super().remove(*args, **kwargs) 116 | 117 | @save_after 118 | def pop(self, *args, **kwargs): 119 | super().pop(*args, **kwargs) 120 | 121 | @save_after 122 | def clear(self, *args, **kwargs): 123 | super().clear(*args, **kwargs) 124 | 125 | @save_after 126 | def sort(self, *args, **kwargs): 127 | super().sort(*args, **kwargs) 128 | 129 | @save_after 130 | def reverse(self, *args, **kwargs): 131 | super().reverse(*args, **kwargs) 132 | 133 | @save_after 134 | def popitem(self, *args, **kwargs): 135 | super().popitem(*args, **kwargs) 136 | 137 | @save_after 138 | def update(self, *args, **kwargs): 139 | super().update(*args, **kwargs) 140 | 141 | 142 | _LOAD_BEFORE_METHODS = [ 143 | '__getattribute__', 144 | '__iter__', 145 | '__getitem__', 146 | ] 147 | _SAVE_AFTER_METHODS = [ 148 | '__setattr__', 149 | '__setitem__', 150 | '__delitem__', 151 | 'append', 152 | 'extend', 153 | 'insert', 154 | 'remove', 155 | 'pop', 156 | 'clear', 157 | 'sort', 158 | 'reverse', 159 | 'popitem', 160 | 'update', 161 | ] 162 | 163 | 164 | def patch_methods(instance): 165 | log.debug("Patching methods on: %r", instance) 166 | cls = instance.__class__ 167 | 168 | for name in _LOAD_BEFORE_METHODS: 169 | try: 170 | method = getattr(cls, name) 171 | except AttributeError: 172 | log.trace("No method: %s", name) 173 | else: 174 | modified_method = load_before(method) 175 | setattr(cls, name, modified_method) 176 | log.trace("Patched to load before call: %s", name) 177 | 178 | for name in _SAVE_AFTER_METHODS: 179 | try: 180 | method = getattr(cls, name) 181 | except AttributeError: 182 | log.trace("No method: %s", name) 183 | else: 184 | modified_method = save_after(method) 185 | setattr(cls, name, modified_method) 186 | log.trace("Patched to save after call: %s", name) 187 | -------------------------------------------------------------------------------- /yorm/common.py: -------------------------------------------------------------------------------- 1 | """Shared internal classes and functions.""" 2 | 3 | import collections 4 | import logging 5 | 6 | 7 | # CONSTANTS ################################################################### 8 | 9 | MAPPER = '__mapper__' 10 | ALL = 'all' 11 | UUID = 'UUID' 12 | 13 | PRINT_VERBOSITY = 0 # minimum verbosity to using `print` 14 | STR_VERBOSITY = 3 # minimum verbosity to use verbose `__str__` 15 | MAX_VERBOSITY = 4 # maximum verbosity level implemented 16 | 17 | OVERRIDE_MESSAGE = "Method must be implemented in subclasses" 18 | 19 | 20 | # GLOBALS ##################################################################### 21 | 22 | 23 | verbosity = 0 # global verbosity setting for controlling string formatting 24 | 25 | attrs = collections.defaultdict(collections.OrderedDict) 26 | path_formats = {} 27 | 28 | 29 | # LOGGING ##################################################################### 30 | 31 | 32 | logging.addLevelName(logging.DEBUG - 1, 'TRACE') 33 | 34 | 35 | def _trace(self, message, *args, **kwargs): 36 | if self.isEnabledFor(logging.DEBUG - 1): 37 | # pylint: disable=protected-access 38 | self._log(logging.DEBUG - 1, message, args, **kwargs) 39 | 40 | 41 | logging.Logger.trace = _trace 42 | 43 | 44 | # DECORATORS ################################################################## 45 | 46 | 47 | class classproperty: 48 | """Read-only class property decorator.""" 49 | 50 | def __init__(self, getter): 51 | self.getter = getter 52 | 53 | def __get__(self, instance, owner): 54 | return self.getter(owner) 55 | 56 | 57 | # FUNCTIONS ################################################################### 58 | 59 | 60 | def get_mapper(obj, *, expected=None): 61 | """Get the `Mapper` instance attached to an object.""" 62 | try: 63 | mapper = object.__getattribute__(obj, MAPPER) 64 | except AttributeError: 65 | mapper = None 66 | 67 | if mapper and expected is False: 68 | msg = "{!r} is already mapped".format(obj) 69 | raise TypeError(msg) 70 | 71 | if not mapper and expected is True: 72 | msg = "{!r} is not mapped".format(obj) 73 | raise TypeError(msg) 74 | 75 | return mapper 76 | 77 | 78 | def set_mapper(obj, mapper): 79 | """Attach a `Mapper` instance to an object.""" 80 | setattr(obj, MAPPER, mapper) 81 | return mapper 82 | -------------------------------------------------------------------------------- /yorm/decorators.py: -------------------------------------------------------------------------------- 1 | """Functions to enable mapping on classes and instances.""" 2 | 3 | import uuid 4 | from collections import OrderedDict 5 | import logging 6 | 7 | from . import common 8 | from .bases.mappable import patch_methods 9 | from .mapper import Mapper 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | def sync(*args, **kwargs): 15 | """Decorate class or map object based on arguments. 16 | 17 | This function will call either: 18 | 19 | * `sync_object` - when given an unmapped object 20 | * `sync_instances` - when used as the class decorator 21 | 22 | Consult the signature of each call for more information. 23 | 24 | """ 25 | if 'path_format' in kwargs or args and isinstance(args[0], str): 26 | return sync_instances(*args, **kwargs) 27 | else: 28 | return sync_object(*args, **kwargs) 29 | 30 | 31 | def sync_object(instance, path, attrs=None, **kwargs): 32 | """Enable YAML mapping on an object. 33 | 34 | :param instance: object to patch with YAML mapping behavior 35 | :param path: file path for dump/parse 36 | :param attrs: dictionary of attribute names mapped to converter classes 37 | 38 | :param auto_create: automatically create the file to save attributes 39 | :param auto_save: automatically save attribute changes to the file 40 | :param auto_track: automatically add new attributes from the file 41 | 42 | """ 43 | log.info("Mapping %r to %s...", instance, path) 44 | 45 | common.get_mapper(instance, expected=False) 46 | patch_methods(instance) 47 | 48 | attrs = _ordered(attrs) or common.attrs[instance.__class__] 49 | mapper = Mapper(instance, path, attrs, **kwargs) 50 | 51 | if mapper.missing: 52 | if mapper.auto_create: 53 | mapper.create() 54 | if mapper.auto_save: 55 | mapper.save() 56 | mapper.load() 57 | else: 58 | mapper.load() 59 | 60 | common.set_mapper(instance, mapper) 61 | log.info("Mapped %r to %s", instance, path) 62 | 63 | return instance 64 | 65 | 66 | def sync_instances(path_format, format_spec=None, attrs=None, **kwargs): 67 | """Decorate class to enable YAML mapping after instantiation. 68 | 69 | :param path_format: formatting string to create file paths for dump/parse 70 | :param format_spec: dictionary to use for string formatting 71 | :param attrs: dictionary of attribute names mapped to converter classes 72 | 73 | :param auto_create: automatically create the file to save attributes 74 | :param auto_save: automatically save attribute changes to the file 75 | :param auto_track: automatically add new attributes from the file 76 | 77 | """ 78 | format_spec = format_spec or {} 79 | attrs = attrs or OrderedDict() 80 | 81 | def decorator(cls): 82 | """Class decorator to map instances to files.""" 83 | common.path_formats[cls] = path_format 84 | init = cls.__init__ 85 | 86 | def modified_init(self, *_args, **_kwargs): 87 | init(self, *_args, **_kwargs) 88 | 89 | log.info("Mapping instance of %r to '%s'...", cls, path_format) 90 | 91 | format_values = {} 92 | for key, value in format_spec.items(): 93 | format_values[key] = getattr(self, value) 94 | if '{' + common.UUID + '}' in path_format: 95 | format_values[common.UUID] = uuid.uuid4().hex 96 | format_values['self'] = self 97 | 98 | common.attrs[cls].update(attrs) 99 | common.attrs[cls].update(common.attrs[self.__class__]) 100 | path = path_format.format(**format_values) 101 | sync_object(self, path, **kwargs) 102 | 103 | modified_init.__doc__ = init.__doc__ 104 | cls.__init__ = modified_init 105 | 106 | return cls 107 | 108 | return decorator 109 | 110 | 111 | def attr(**kwargs): 112 | """Class decorator to map attributes to types. 113 | 114 | :param kwargs: keyword arguments mapping attribute name to converter class 115 | 116 | """ 117 | if len(kwargs) != 1: 118 | raise ValueError("Single attribute required: {}".format(kwargs)) 119 | 120 | def decorator(cls): 121 | """Class decorator.""" 122 | previous = common.attrs[cls] 123 | common.attrs[cls] = OrderedDict() 124 | for name, converter in kwargs.items(): 125 | common.attrs[cls][name] = converter 126 | for name, converter in previous.items(): 127 | common.attrs[cls][name] = converter 128 | 129 | return cls 130 | 131 | return decorator 132 | 133 | 134 | def _ordered(data): 135 | """Sort a dictionary-like object by key.""" 136 | if data is None: 137 | return None 138 | return OrderedDict(sorted(data.items(), key=lambda pair: pair[0])) 139 | -------------------------------------------------------------------------------- /yorm/diskutils.py: -------------------------------------------------------------------------------- 1 | """Functions to work with files and data formats.""" 2 | 3 | import os 4 | import shutil 5 | import logging 6 | 7 | import yaml 8 | import simplejson as json 9 | 10 | from . import exceptions 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | def exists(path): 16 | """Determine if a path exists.""" 17 | return os.path.exists(path) 18 | 19 | 20 | def touch(path): 21 | """Ensure a file path exists.""" 22 | if not os.path.exists(path): 23 | dirpath = os.path.dirname(path) 24 | if dirpath and not os.path.isdir(dirpath): 25 | log.trace("Creating directory '{}'...".format(dirpath)) 26 | os.makedirs(dirpath) 27 | log.trace("Creating empty '{}'...".format(path)) 28 | write("", path) 29 | 30 | 31 | def read(path, encoding='utf-8'): 32 | """Read text from a file. 33 | 34 | :param path: file path to read from 35 | :param encoding: input file encoding 36 | 37 | :return: string contents of file 38 | 39 | """ 40 | log.trace("Reading text from '{}'...".format(path)) 41 | 42 | with open(path, 'r', encoding=encoding) as stream: 43 | text = stream.read() 44 | 45 | return text 46 | 47 | 48 | def write(text, path, encoding='utf-8'): 49 | """Write text to a file. 50 | 51 | :param text: string 52 | :param path: file path to write text 53 | :param encoding: output file encoding 54 | 55 | :return: path of file 56 | 57 | """ 58 | if text: 59 | log.trace("Writing text to '{}'...".format(path)) 60 | 61 | with open(path, 'wb') as stream: 62 | data = text.encode(encoding) 63 | stream.write(data) 64 | 65 | return path 66 | 67 | 68 | def stamp(path): 69 | """Get the modification timestamp from a file.""" 70 | return os.path.getmtime(path) 71 | 72 | 73 | def delete(path): 74 | """Delete a file or directory.""" 75 | if os.path.isdir(path): 76 | try: 77 | log.trace("Deleting '{}'...".format(path)) 78 | shutil.rmtree(path) 79 | except IOError: 80 | # bug: http://code.activestate.com/lists/python-list/159050 81 | msg = "Unable to delete: {}".format(path) 82 | log.warning(msg) 83 | elif os.path.isfile(path): 84 | log.trace("Deleting '{}'...".format(path)) 85 | os.remove(path) 86 | 87 | 88 | def parse(text, path): 89 | """Parse a dictionary of data from formatted text. 90 | 91 | :param text: string containing dumped data 92 | :param path: file path to specify formatting 93 | 94 | :return: dictionary of data 95 | 96 | """ 97 | ext = _get_ext(path) 98 | if ext in ['json']: 99 | data = _parse_json(text, path) 100 | elif ext in ['yml', 'yaml']: 101 | data = _parse_yaml(text, path) 102 | else: 103 | log.warning("Unrecognized file extension (.%s), assuming YAML", ext) 104 | data = _parse_yaml(text, path) 105 | 106 | if not isinstance(data, dict): 107 | msg = "Invalid file contents: {}".format(path) 108 | raise exceptions.FileContentError(msg) 109 | 110 | return data 111 | 112 | 113 | def _parse_json(text, path): 114 | try: 115 | return json.loads(text) or {} 116 | except json.JSONDecodeError: 117 | msg = "Invalid JSON contents: {}:\n{}".format(path, text) 118 | raise exceptions.FileContentError(msg) 119 | 120 | 121 | def _parse_yaml(text, path): 122 | try: 123 | return yaml.safe_load(text) or {} 124 | except yaml.error.YAMLError: 125 | msg = "Invalid YAML contents: {}:\n{}".format(path, text) 126 | raise exceptions.FileContentError(msg) 127 | 128 | 129 | def dump(data, path): 130 | """Format a dictionary into a serialization format. 131 | 132 | :param text: dictionary of data to format 133 | :param path: file path to specify formatting 134 | 135 | :return: string of formatted data 136 | 137 | """ 138 | ext = _get_ext(path) 139 | 140 | if ext in ['json']: 141 | return json.dumps(data, indent=4, sort_keys=True) 142 | 143 | if ext not in ['yml', 'yaml']: 144 | log.warning("Unrecognized file extension (.%s), assuming YAML", ext) 145 | 146 | return yaml.dump(data, default_flow_style=False, allow_unicode=True) 147 | 148 | 149 | def _get_ext(path): 150 | if '.' in path: 151 | return path.split('.')[-1].lower() 152 | else: 153 | return 'yml' 154 | -------------------------------------------------------------------------------- /yorm/exceptions.py: -------------------------------------------------------------------------------- 1 | """Custom exceptions.""" 2 | 3 | from abc import ABCMeta 4 | 5 | import yaml 6 | 7 | 8 | class Error(Exception, metaclass=ABCMeta): 9 | """Base class for all YORM exceptions.""" 10 | 11 | 12 | class DuplicateMappingError(Error, FileExistsError): 13 | """The file is already in use by another mapping.""" 14 | 15 | 16 | class MissingFileError(Error, FileNotFoundError): 17 | """An object's file has not yet been created.""" 18 | 19 | 20 | class DeletedFileError(Error, FileNotFoundError): 21 | """An object's file was deleted.""" 22 | 23 | 24 | class FileContentError(Error, yaml.error.YAMLError, ValueError): 25 | """Text could not be parsed as valid YAML.""" 26 | -------------------------------------------------------------------------------- /yorm/mapper.py: -------------------------------------------------------------------------------- 1 | """Core object-file mapping functionality.""" 2 | 3 | import functools 4 | from pprint import pformat 5 | import logging 6 | 7 | from . import common, diskutils, exceptions, types, settings 8 | from .bases import Container 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | def file_required(method): 14 | """Decorate methods that require the file to exist.""" 15 | 16 | @functools.wraps(method) 17 | def wrapped(self, *args, **kwargs): 18 | if self.deleted: 19 | msg = "File deleted: {}".format(self.path) 20 | raise exceptions.DeletedFileError(msg) 21 | if self.missing and not settings.fake: 22 | msg = "File missing: {}".format(self.path) 23 | raise exceptions.MissingFileError(msg) 24 | return method(self, *args, **kwargs) 25 | 26 | return wrapped 27 | 28 | 29 | def prevent_recursion(method): 30 | """Decorate methods to prevent indirect recursive calls.""" 31 | 32 | @functools.wraps(method) 33 | def wrapped(self, *args, **kwargs): 34 | # pylint: disable=protected-access 35 | if self._activity: 36 | return None 37 | self._activity = True 38 | result = method(self, *args, **kwargs) 39 | self._activity = False 40 | return result 41 | 42 | return wrapped 43 | 44 | 45 | def prefix(obj): 46 | """Prefix a string with a fake designator if enabled.""" 47 | fake = "(fake) " if settings.fake else "" 48 | name = obj if isinstance(obj, str) else "'{}'".format(obj) 49 | return fake + name 50 | 51 | 52 | class Mapper: 53 | """Utility class to map an object's attributes to a file. 54 | 55 | To start mapping attributes to a file: 56 | 57 | create -> [empty] -> FILE 58 | 59 | When getting an attribute: 60 | 61 | FILE -> read -> [text] -> parse -> [dict] -> load -> ATTRIBUTES 62 | 63 | When setting an attribute: 64 | 65 | ATTRIBUTES -> save -> [dict] -> dump -> [text] -> write -> FILE 66 | 67 | After the mapped file is no longer needed: 68 | 69 | delete -> [null] -> FILE 70 | 71 | """ 72 | 73 | def __init__(self, obj, path, attrs, *, 74 | auto_create=True, auto_save=True, 75 | auto_track=False, auto_resolve=False): 76 | self._obj = obj 77 | self.path = path 78 | self.attrs = attrs 79 | self.auto_create = auto_create 80 | self.auto_save = auto_save 81 | self.auto_track = auto_track 82 | self.auto_resolve = auto_resolve 83 | 84 | self.exists = diskutils.exists(self.path) 85 | self.deleted = False 86 | self.auto_save_after_load = False 87 | 88 | self._activity = False 89 | self._timestamp = 0 90 | self._fake = "" 91 | 92 | def __str__(self): 93 | return str(self.path) 94 | 95 | @property 96 | def missing(self): 97 | return not self.exists 98 | 99 | @property 100 | def modified(self): 101 | """Determine if the file has been modified.""" 102 | if settings.fake: 103 | changes = self._timestamp is not None 104 | return changes 105 | elif not self.exists: 106 | return True 107 | else: 108 | # TODO: this raises an exception is the file is missing 109 | was = self._timestamp 110 | now = diskutils.stamp(self.path) 111 | return was != now 112 | 113 | @modified.setter 114 | @file_required 115 | def modified(self, changes): 116 | """Mark the file as modified if there are changes.""" 117 | if changes: 118 | log.debug("Marked %s as modified", prefix(self)) 119 | self._timestamp = 0 120 | else: 121 | if settings.fake or self.path is None: 122 | self._timestamp = None 123 | else: 124 | self._timestamp = diskutils.stamp(self.path) 125 | log.debug("Marked %s as unmodified", prefix(self)) 126 | 127 | @property 128 | def text(self): 129 | """Get file contents as a string.""" 130 | log.info("Getting contents of %s...", prefix(self)) 131 | if settings.fake: 132 | text = self._fake 133 | else: 134 | text = self._read() 135 | log.trace("Text read: \n%s", text[:-1]) 136 | return text 137 | 138 | @text.setter 139 | def text(self, text): 140 | """Set file contents from a string.""" 141 | log.info("Setting contents of %s...", prefix(self)) 142 | if settings.fake: 143 | self._fake = text 144 | else: 145 | self._write(text) 146 | log.trace("Text wrote: \n%s", text.rstrip()) 147 | self.modified = True 148 | 149 | @property 150 | def data(self): 151 | """Get the file values as a dictionary.""" 152 | text = self._read() 153 | try: 154 | data = diskutils.parse(text, self.path) 155 | except ValueError as e: 156 | if not self.auto_resolve: 157 | raise e from None 158 | 159 | log.debug(e) 160 | log.warning("Clearing invalid contents: %s", self.path) 161 | self._write("") 162 | return {} 163 | 164 | log.trace("Parsed data: \n%s", pformat(data)) 165 | return data 166 | 167 | @data.setter 168 | def data(self, data): 169 | """Set the file values from a dictionary.""" 170 | text = diskutils.dump(data, self.path) 171 | self._write(text) 172 | 173 | def create(self): 174 | """Create a new file for the object.""" 175 | log.info("Creating %s for %r...", prefix(self), self._obj) 176 | if self.exists: 177 | log.warning("Already created: %s", self) 178 | return 179 | if not settings.fake: 180 | diskutils.touch(self.path) 181 | self.exists = True 182 | self.deleted = False 183 | 184 | @file_required 185 | @prevent_recursion 186 | def load(self): 187 | """Update the object's mapped attributes from its file.""" 188 | log.info("Loading %r from %s...", self._obj, prefix(self)) 189 | 190 | # Update all attributes 191 | attrs2 = self.attrs.copy() 192 | for name, data in self.data.items(): 193 | attrs2.pop(name, None) 194 | 195 | # Find a matching converter 196 | try: 197 | converter = self.attrs[name] 198 | except KeyError: 199 | if self.auto_track: 200 | converter = types.match(name, data) 201 | self.attrs[name] = converter 202 | else: 203 | msg = "Ignored unknown file attribute: %s = %r" 204 | log.warning(msg, name, data) 205 | continue 206 | 207 | # Convert the parsed value to the attribute's final type 208 | attr = getattr(self._obj, name, None) 209 | if isinstance(attr, converter) and \ 210 | issubclass(converter, Container): 211 | attr.update_value(data, auto_track=self.auto_track) 212 | else: 213 | log.trace("Converting attribute %r using %r", name, converter) 214 | attr = converter.to_value(data) 215 | setattr(self._obj, name, attr) 216 | self._remap(attr, self) 217 | log.trace("Value loaded: %s = %r", name, attr) 218 | 219 | # Add missing attributes 220 | for name, converter in attrs2.items(): 221 | try: 222 | existing_attr = getattr(self._obj, name) 223 | except AttributeError: 224 | value = converter.create_default() 225 | msg = "Default value for missing object attribute: %s = %r" 226 | log.warning(msg, name, value) 227 | setattr(self._obj, name, value) 228 | self._remap(value, self) 229 | else: 230 | if issubclass(converter, Container): 231 | if isinstance(existing_attr, converter): 232 | pass # TODO: Update 'existing_attr' values to replace None values 233 | else: 234 | msg = "Converting container attribute %r using %r" 235 | log.trace(msg, name, converter) 236 | value = converter.create_default() 237 | setattr(self._obj, name, value) 238 | self._remap(value, self) 239 | else: 240 | pass # TODO: Figure out when this case occurs 241 | 242 | # Set meta attributes 243 | self.modified = False 244 | 245 | def _remap(self, obj, root): 246 | """Attach mapper on nested attributes.""" 247 | if isinstance(obj, Container): 248 | common.set_mapper(obj, root) 249 | 250 | if isinstance(obj, dict): 251 | for obj2 in obj.values(): 252 | self._remap(obj2, root) 253 | else: 254 | assert isinstance(obj, list) 255 | for obj2 in obj: 256 | self._remap(obj2, root) 257 | 258 | @file_required 259 | @prevent_recursion 260 | def save(self): 261 | """Format and save the object's mapped attributes to its file.""" 262 | log.info("Saving %r to %s...", self._obj, prefix(self)) 263 | 264 | # Format the data items 265 | data = self.attrs.__class__() 266 | for name, converter in self.attrs.items(): 267 | try: 268 | value = getattr(self._obj, name) 269 | except AttributeError: 270 | data2 = converter.to_data(None) 271 | msg = "Default data for missing object attribute: %s = %r" 272 | log.warning(msg, name, data2) 273 | else: 274 | data2 = converter.to_data(value) 275 | 276 | log.trace("Data to save: %s = %r", name, data2) 277 | data[name] = data2 278 | 279 | # Save the formatted to disk 280 | self.data = data 281 | 282 | # Set meta attributes 283 | self.modified = True 284 | self.auto_save_after_load = self.auto_save 285 | 286 | def delete(self): 287 | """Delete the object's file from the file system.""" 288 | if self.exists: 289 | log.info("Deleting %s...", prefix(self)) 290 | diskutils.delete(self.path) 291 | else: 292 | log.warning("Already deleted: %s", self) 293 | self.exists = False 294 | self.deleted = True 295 | 296 | @file_required 297 | def _read(self): 298 | """Read text from the object's file.""" 299 | if settings.fake: 300 | return self._fake 301 | elif not self.exists: 302 | return "" 303 | else: 304 | return diskutils.read(self.path) 305 | 306 | @file_required 307 | def _write(self, text): 308 | """Write text to the object's file.""" 309 | if settings.fake: 310 | self._fake = text 311 | else: 312 | diskutils.write(text, self.path) 313 | -------------------------------------------------------------------------------- /yorm/mixins.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from yorm import utilities 4 | 5 | 6 | class ModelMixin: 7 | """Adds ORM methods to a mapped class.""" 8 | 9 | @classmethod 10 | def create(cls, *args, **kwargs): 11 | return utilities.create(cls, *args, **kwargs) 12 | 13 | @classmethod 14 | def new(cls, *args, **kwargs): 15 | msg = "ModelMixin.new() has been renamed to ModelMixin.create()" 16 | warnings.warn(msg, DeprecationWarning) 17 | return utilities.create(cls, *args, **kwargs) 18 | 19 | @classmethod 20 | def find(cls, *args, **kwargs): 21 | return utilities.find(cls, *args, **kwargs) 22 | 23 | @classmethod 24 | def match(cls, *args, **kwargs): 25 | return utilities.match(cls, *args, **kwargs) 26 | 27 | def load(self): 28 | return utilities.load(self) 29 | 30 | def save(self): 31 | return utilities.save(self) 32 | 33 | def delete(self): 34 | return utilities.delete(self) 35 | -------------------------------------------------------------------------------- /yorm/settings.py: -------------------------------------------------------------------------------- 1 | """Package settings.""" 2 | 3 | fake = False 4 | -------------------------------------------------------------------------------- /yorm/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the package.""" 2 | 3 | import os 4 | import time 5 | import logging 6 | 7 | import expecter 8 | 9 | 10 | def is_none(x): 11 | return x is None 12 | 13 | 14 | def is_true(x): 15 | return x is True 16 | 17 | 18 | def is_false(x): 19 | return x is False 20 | 21 | 22 | def exists(x): 23 | return os.path.exists(x) 24 | 25 | 26 | def missing(x): 27 | return not os.path.exists(x) 28 | 29 | 30 | expecter.add_expectation(is_none) 31 | expecter.add_expectation(is_true) 32 | expecter.add_expectation(is_false) 33 | expecter.add_expectation(exists) 34 | expecter.add_expectation(missing) 35 | 36 | 37 | def strip(text, tabs=None, end='\n'): 38 | """Strip leading whitespace indentation on multiline string literals.""" 39 | lines = [] 40 | 41 | for line in text.strip().splitlines(): 42 | if not tabs: 43 | tabs = line.count(' ' * 4) 44 | lines.append(line.replace(' ' * tabs * 4, '', 1)) 45 | 46 | return '\n'.join(lines) + end 47 | 48 | 49 | def refresh_file_modification_times(seconds=1.1): 50 | """Sleep to allow file modification times to refresh.""" 51 | logging.info("Delaying for %s second%s...", seconds, 52 | "" if seconds == 1 else "s") 53 | time.sleep(seconds) 54 | 55 | 56 | def log(pattern, *args, width=60): 57 | message = pattern % args 58 | logging.info((' ' + message).rjust(width, '=')) 59 | -------------------------------------------------------------------------------- /yorm/tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Unit tests configuration file.""" 2 | 3 | import logging 4 | 5 | 6 | def pytest_configure(config): 7 | """Conigure logging and silence verbose test runner output.""" 8 | logging.basicConfig( 9 | level=logging.DEBUG - 1, 10 | format="[%(levelname)-8s] (%(name)s @%(lineno)4d) %(message)s", 11 | ) 12 | 13 | terminal = config.pluginmanager.getplugin('terminal') 14 | base = terminal.TerminalReporter 15 | 16 | class QuietReporter(base): 17 | """A py.test reporting that only shows dots when running tests.""" 18 | 19 | def __init__(self, *args, **kwargs): 20 | super().__init__(*args, **kwargs) 21 | self.verbosity = 0 22 | self.showlongtestinfo = False 23 | self.showfspath = False 24 | 25 | terminal.TerminalReporter = QuietReporter 26 | -------------------------------------------------------------------------------- /yorm/tests/test_bases_container.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,no-self-use,misplaced-comparison-constant,abstract-class-instantiated 2 | 3 | import pytest 4 | 5 | from yorm.bases import Container 6 | 7 | 8 | class TestContainer: 9 | """Unit tests for the `Container` class.""" 10 | 11 | class MyContainer(Container): 12 | 13 | def __init__(self, number): 14 | from unittest.mock import MagicMock 15 | self.__mapper__ = MagicMock() 16 | self.value = number 17 | 18 | @classmethod 19 | def create_default(cls): 20 | return 1 21 | 22 | @classmethod 23 | def to_data(cls, value): 24 | return str(value.value) 25 | 26 | def update_value(self, data, *, auto_track=None): # pylint: disable=unused-argument 27 | self.value += int(data) 28 | 29 | def test_container_class_cannot_be_instantiated(self): 30 | with pytest.raises(TypeError): 31 | Container() 32 | 33 | def test_container_instance_methods_can_be_called(self): 34 | container = self.MyContainer(42) 35 | assert 42 == container.value 36 | container.update_value(10) 37 | assert 52 == container.value 38 | assert "52" == container.format_data() 39 | -------------------------------------------------------------------------------- /yorm/tests/test_bases_converter.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,no-self-use,abstract-class-instantiated 2 | 3 | import pytest 4 | 5 | from yorm.bases import Converter 6 | 7 | 8 | class TestConverter: 9 | 10 | """Unit tests for the `Converter` class.""" 11 | 12 | def test_converter_class_cannot_be_instantiated(self): 13 | with pytest.raises(TypeError): 14 | Converter() 15 | 16 | def test_converter_class_methods_cannot_be_called(self): 17 | with pytest.raises(NotImplementedError): 18 | Converter.create_default() 19 | with pytest.raises(NotImplementedError): 20 | Converter.to_value(None) 21 | with pytest.raises(NotImplementedError): 22 | Converter.to_data(None) 23 | -------------------------------------------------------------------------------- /yorm/tests/test_bases_mappable.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,no-self-use,attribute-defined-outside-init,protected-access,misplaced-comparison-constant 2 | 3 | import logging 4 | from unittest.mock import Mock 5 | 6 | import pytest 7 | 8 | import yorm 9 | from yorm.bases import Mappable 10 | from yorm.mapper import Mapper 11 | from yorm.types import String, Integer, Boolean, List, Dictionary 12 | 13 | from . import strip 14 | 15 | 16 | class MockMapper(Mapper): 17 | """Mapped file with stubbed file IO.""" 18 | 19 | def __init__(self, obj, path, attrs): 20 | super().__init__(obj, path, attrs) 21 | self._mock_file = None 22 | self._mock_modified = True 23 | self.exists = True 24 | 25 | def _read(self): 26 | text = self._mock_file 27 | logging.debug("Mock read:\n%s", text.strip()) 28 | return text 29 | 30 | def _write(self, text): 31 | logging.debug("Mock write:\n%s", text.strip()) 32 | self._mock_file = text 33 | self.modified = True 34 | 35 | @property 36 | def modified(self): 37 | return self._mock_modified 38 | 39 | @modified.setter 40 | def modified(self, changes): 41 | self._mock_modified = changes 42 | 43 | 44 | # CLASSES ##################################################################### 45 | 46 | @yorm.attr(all=Integer) 47 | class IntegerList(List): 48 | """List of integers.""" 49 | 50 | 51 | @yorm.attr(status=Boolean) 52 | class StatusDictionary(Dictionary): 53 | """Dictionary of statuses.""" 54 | 55 | 56 | class SampleMappable(Mappable): 57 | """Sample mappable class with hard-coded settings.""" 58 | 59 | def __init__(self): 60 | self.__mapper__ = None 61 | 62 | logging.debug("Initializing sample...") 63 | self.var1 = None 64 | self.var2 = None 65 | self.var3 = None 66 | self.var4 = None 67 | self.var5 = None 68 | logging.debug("Sample initialized") 69 | 70 | path = "mock/path/to/sample.yml" 71 | attrs = {'var1': String, 72 | 'var2': Integer, 73 | 'var3': Boolean, 74 | 'var4': IntegerList, 75 | 'var5': StatusDictionary} 76 | self.__mapper__ = MockMapper(self, path, attrs) 77 | self.__mapper__.save() 78 | 79 | def __repr__(self): 80 | return "".format(id(self)) 81 | 82 | 83 | # TESTS ####################################################################### 84 | 85 | 86 | class TestMappable: 87 | """Unit tests for the `Mappable` class.""" 88 | 89 | def setup_method(self, _): 90 | """Create an mappable instance for tests.""" 91 | self.sample = SampleMappable() 92 | 93 | def test_init(self): 94 | """Verify files are created after initialized.""" 95 | text = self.sample.__mapper__._read() 96 | assert strip(""" 97 | var1: '' 98 | var2: 0 99 | var3: false 100 | var4: 101 | - 102 | var5: 103 | status: false 104 | """) == text 105 | 106 | def test_set(self): 107 | """Verify the file is written to after setting an attribute.""" 108 | self.sample.var1 = "abc123" 109 | self.sample.var2 = 1 110 | self.sample.var3 = True 111 | self.sample.var4 = [42] 112 | self.sample.var5 = {'status': True} 113 | text = self.sample.__mapper__._read() 114 | assert strip(""" 115 | var1: abc123 116 | var2: 1 117 | var3: true 118 | var4: 119 | - 42 120 | var5: 121 | status: true 122 | """) == text 123 | 124 | def test_set_converted(self): 125 | """Verify conversion occurs when setting attributes.""" 126 | self.sample.var1 = 42 127 | self.sample.var2 = "1" 128 | self.sample.var3 = 'off' 129 | self.sample.var4 = None 130 | self.sample.var5 = {'status': 1} 131 | text = self.sample.__mapper__._read() 132 | assert strip(""" 133 | var1: 42 134 | var2: 1 135 | var3: false 136 | var4: 137 | - 138 | var5: 139 | status: true 140 | """) == text 141 | 142 | def test_set_error(self): 143 | """Verify an exception is raised when a value cannot be converted.""" 144 | with pytest.raises(ValueError): 145 | self.sample.var2 = "abc" 146 | 147 | def test_get(self): 148 | """Verify the file is read from before getting an attribute.""" 149 | text = strip(""" 150 | var1: def456 151 | var2: 42 152 | var3: off 153 | """) 154 | self.sample.__mapper__._write(text) 155 | assert"def456" == self.sample.var1 156 | assert 42 == self.sample.var2 157 | assert False is self.sample.var3 158 | 159 | def test_error_invalid_yaml(self): 160 | """Verify an exception is raised on invalid YAML.""" 161 | text = strip(""" 162 | invalid: - 163 | """) 164 | self.sample.__mapper__._write(text) 165 | with pytest.raises(ValueError): 166 | print(self.sample.var1) 167 | 168 | def test_error_unexpected_yaml(self): 169 | """Verify an exception is raised on unexpected YAML.""" 170 | text = strip(""" 171 | not a dictionary 172 | """) 173 | self.sample.__mapper__._write(text) 174 | with pytest.raises(ValueError): 175 | print(self.sample.var1) 176 | 177 | def test_new(self): 178 | """Verify new attributes are added to the object.""" 179 | self.sample.__mapper__.auto_track = True 180 | text = strip(""" 181 | new: 42 182 | """) 183 | self.sample.__mapper__._write(text) 184 | assert 42 == self.sample.new 185 | 186 | def test_new_unknown(self): 187 | """Verify an exception is raised on new attributes w/ unknown types""" 188 | self.sample.__mapper__.auto_track = True 189 | text = strip(""" 190 | new: !!timestamp 2001-12-15T02:59:43.1Z 191 | """) 192 | self.sample.__mapper__._write(text) 193 | with pytest.raises(ValueError): 194 | print(self.sample.var1) 195 | 196 | 197 | class TestMappableTriggers: 198 | 199 | class MockDict(Mappable, dict): 200 | pass 201 | 202 | class MockList: 203 | 204 | def append(self, value): 205 | print(value) 206 | 207 | def insert(self, value): 208 | print(value) 209 | 210 | class Sample(MockDict, MockList): 211 | 212 | __mapper__ = Mock() 213 | __mapper__.attrs = {} 214 | __mapper__.load = Mock() 215 | __mapper__.save = Mock() 216 | 217 | def setup_method(self, _): 218 | """Create an mappable instance for tests.""" 219 | self.sample = self.Sample() 220 | self.sample.__mapper__.load.reset_mock() 221 | self.sample.__mapper__.save.reset_mock() 222 | self.sample.__mapper__.auto_save_after_load = False 223 | 224 | def test_getattribute(self): 225 | with pytest.raises(AttributeError): 226 | getattr(self.sample, 'foo') 227 | assert 1 == self.sample.__mapper__.load.call_count 228 | assert 0 == self.sample.__mapper__.save.call_count 229 | 230 | def test_setattr(self): 231 | self.sample.__mapper__.attrs['foo'] = Mock() 232 | setattr(self.sample, 'foo', 'bar') 233 | assert 0 == self.sample.__mapper__.load.call_count 234 | assert 1 == self.sample.__mapper__.save.call_count 235 | 236 | def test_getitem(self): 237 | with pytest.raises(KeyError): 238 | print(self.sample['foo']) 239 | assert 1 == self.sample.__mapper__.load.call_count 240 | assert 0 == self.sample.__mapper__.save.call_count 241 | 242 | def test_setitem(self): 243 | self.sample['foo'] = 'bar' 244 | assert 0 == self.sample.__mapper__.load.call_count 245 | assert 1 == self.sample.__mapper__.save.call_count 246 | 247 | def test_delitem(self): 248 | self.sample['foo'] = 'bar' 249 | self.sample.__mapper__.save.reset_mock() 250 | 251 | del self.sample['foo'] 252 | assert 0 == self.sample.__mapper__.load.call_count 253 | assert 1 == self.sample.__mapper__.save.call_count 254 | 255 | def test_append(self): 256 | self.sample.append('foo') 257 | assert 1 == self.sample.__mapper__.load.call_count 258 | assert 1 == self.sample.__mapper__.save.call_count 259 | 260 | def test_insert(self): 261 | self.sample.insert('foo') 262 | assert 1 == self.sample.__mapper__.load.call_count 263 | assert 1 == self.sample.__mapper__.save.call_count 264 | 265 | def test_iter(self): 266 | self.sample.append('foo') 267 | self.sample.append('bar') 268 | self.sample.__mapper__.load.reset_mock() 269 | self.sample.__mapper__.save.reset_mock() 270 | self.sample.__mapper__.auto_save_after_load = False 271 | self.sample.__mapper__.modified = True 272 | 273 | for item in self.sample: 274 | print(item) 275 | assert 1 == self.sample.__mapper__.load.call_count 276 | assert 0 == self.sample.__mapper__.save.call_count 277 | 278 | def test_handle_missing_mapper(self): 279 | sample = self.MockDict() 280 | sample.__mapper__ = None 281 | sample[0] = 0 282 | print(sample[0]) 283 | assert None is sample.__mapper__ 284 | -------------------------------------------------------------------------------- /yorm/tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-variable,unused-argument,expression-not-assigned 2 | # pylint: disable=missing-docstring,no-self-use,no-member,misplaced-comparison-constant 3 | 4 | import logging 5 | from unittest.mock import patch, Mock 6 | 7 | import pytest 8 | from expecter import expect 9 | 10 | from yorm import decorators 11 | from yorm.bases import Converter 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | class MockConverter(Converter): 17 | """Sample converter class.""" 18 | 19 | @classmethod 20 | def create_default(cls): 21 | return None 22 | 23 | @classmethod 24 | def to_value(cls, data): 25 | return None 26 | 27 | @classmethod 28 | def to_data(cls, value): 29 | return None 30 | 31 | 32 | def describe_sync(): 33 | 34 | def describe_object(): 35 | 36 | @pytest.fixture 37 | def instance(): 38 | cls = type('Sample', (), {}) 39 | instance = cls() 40 | return instance 41 | 42 | @pytest.fixture 43 | def path(tmpdir): 44 | tmpdir.chdir() 45 | return "sample.yml" 46 | 47 | def with_no_attrs(instance, path): 48 | sample = decorators.sync(instance, path) 49 | 50 | expect(sample.__mapper__.path) == "sample.yml" 51 | expect(sample.__mapper__.attrs) == {} 52 | 53 | def with_attrs(instance, path): 54 | attrs = {'var1': MockConverter} 55 | sample = decorators.sync(instance, path, attrs) 56 | 57 | expect(sample.__mapper__.path) == "sample.yml" 58 | expect(sample.__mapper__.attrs) == {'var1': MockConverter} 59 | 60 | def cannot_be_called_twice(instance, path): 61 | sample = decorators.sync(instance, path) 62 | 63 | with pytest.raises(TypeError): 64 | decorators.sync(instance, path) 65 | 66 | @patch('yorm.diskutils.exists', Mock(return_value=True)) 67 | @patch('yorm.diskutils.read', Mock(return_value="abc: 123")) 68 | @patch('yorm.diskutils.stamp', Mock()) 69 | def reads_existing_files(instance, path): 70 | sample = decorators.sync(instance, path, auto_track=True) 71 | 72 | expect(sample.abc) == 123 73 | 74 | 75 | @patch('yorm.diskutils.write', Mock()) 76 | @patch('yorm.diskutils.stamp', Mock()) 77 | @patch('yorm.diskutils.read', Mock(return_value="")) 78 | class TestSyncInstances: 79 | """Unit tests for the `sync_instances` decorator.""" 80 | 81 | @decorators.sync("sample.yml") 82 | class SampleDecorated: 83 | """Sample decorated class.""" 84 | 85 | def __repr__(self): 86 | return "".format(id(self)) 87 | 88 | @decorators.sync("sample.yml", auto_track=True) 89 | class SampleDecoratedAutoTrack: 90 | """Sample decorated class with automatic attribute tracking.""" 91 | 92 | def __repr__(self): 93 | return "".format(id(self)) 94 | 95 | @decorators.sync("{UUID}.yml") 96 | class SampleDecoratedIdentifiers: 97 | """Sample decorated class using UUIDs for paths.""" 98 | 99 | def __repr__(self): 100 | return "".format(id(self)) 101 | 102 | @decorators.sync("tmp/path/to/{n}.yml", {'n': 'name'}) 103 | class SampleDecoratedAttributes: 104 | """Sample decorated class using an attribute value for paths.""" 105 | 106 | def __init__(self, name): 107 | self.name = name 108 | 109 | def __repr__(self): 110 | return "".format(id(self)) 111 | 112 | @decorators.sync("tmp/path/to/{self.name}.yml") 113 | class SampleDecoratedAttributesAutomatic: 114 | """Sample decorated class using an attribute value for paths.""" 115 | 116 | def __init__(self, name): 117 | self.name = name 118 | 119 | def __repr__(self): 120 | return "".format(id(self)) 121 | 122 | @decorators.sync("{self.a}/{self.b}/{c}.yml", {'self.b': 'b', 'c': 'c'}) 123 | class SampleDecoratedAttributesCombination: 124 | """Sample decorated class using an attribute value for paths.""" 125 | 126 | def __init__(self, a, b, c): 127 | self.a = a 128 | self.b = b 129 | self.c = c 130 | 131 | def __repr__(self): 132 | return "".format(id(self)) 133 | 134 | @decorators.sync("sample.yml", attrs={'var1': MockConverter}) 135 | class SampleDecoratedWithAttributes: 136 | """Sample decorated class using a single path.""" 137 | 138 | def test_no_attrs(self): 139 | """Verify mapping can be enabled with no attributes.""" 140 | sample = self.SampleDecorated() 141 | 142 | expect(sample.__mapper__.path) == "sample.yml" 143 | expect(sample.__mapper__.attrs) == {} 144 | 145 | def test_with_attrs(self): 146 | """Verify mapping can be enabled with with attributes.""" 147 | sample = self.SampleDecoratedWithAttributes() 148 | assert "sample.yml" == sample.__mapper__.path 149 | assert ['var1'] == list(sample.__mapper__.attrs.keys()) 150 | 151 | @patch('yorm.diskutils.exists', Mock(return_value=True)) 152 | def test_init_existing(self): 153 | """Verify an existing file is read.""" 154 | with patch('yorm.diskutils.read', Mock(return_value="abc: 123")): 155 | sample = self.SampleDecoratedAutoTrack() 156 | assert 123 == sample.abc 157 | 158 | @patch('uuid.uuid4', Mock(return_value=Mock(hex='abc123'))) 159 | def test_filename_uuid(self): 160 | """Verify UUIDs can be used for filename.""" 161 | sample = self.SampleDecoratedIdentifiers() 162 | assert "abc123.yml" == sample.__mapper__.path 163 | assert {} == sample.__mapper__.attrs 164 | 165 | def test_filename_attributes(self): 166 | """Verify attributes can be used to determine filename.""" 167 | sample1 = self.SampleDecoratedAttributes('one') 168 | sample2 = self.SampleDecoratedAttributes('two') 169 | assert "tmp/path/to/one.yml" == sample1.__mapper__.path 170 | assert "tmp/path/to/two.yml" == sample2.__mapper__.path 171 | 172 | def test_filename_attributes_automatic(self): 173 | """Verify attributes can be used to determine filename (auto save).""" 174 | sample1 = self.SampleDecoratedAttributesAutomatic('one') 175 | sample2 = self.SampleDecoratedAttributesAutomatic('two') 176 | assert "tmp/path/to/one.yml" == sample1.__mapper__.path 177 | assert "tmp/path/to/two.yml" == sample2.__mapper__.path 178 | 179 | def test_filename_attributes_combination(self): 180 | """Verify attributes can be used to determine filename (combo).""" 181 | log.info("Creating first object...") 182 | sample1 = self.SampleDecoratedAttributesCombination('A', 'B', 'C') 183 | log.info("Creating second object...") 184 | sample2 = self.SampleDecoratedAttributesCombination(1, 2, 3) 185 | assert "A/B/C.yml" == sample1.__mapper__.path 186 | assert "1/2/3.yml" == sample2.__mapper__.path 187 | 188 | 189 | def describe_attr(): 190 | 191 | class MockConverter1(MockConverter): 192 | """Sample converter class.""" 193 | 194 | class MockConverter2(MockConverter): 195 | """Sample converter class.""" 196 | 197 | @pytest.fixture 198 | def path(tmpdir): 199 | tmpdir.chdir() 200 | return "mock/path" 201 | 202 | def it_accepts_one_argument(path): 203 | 204 | @decorators.attr(var1=MockConverter1) 205 | @decorators.sync(path) 206 | class SampleDecoratedSingle: 207 | """Class using single `attr` decorator.""" 208 | 209 | sample = SampleDecoratedSingle() 210 | expect(sample.__mapper__.attrs) == {'var1': MockConverter1} 211 | 212 | def it_rejects_zero_arguments(): 213 | with expect.raises(ValueError): 214 | decorators.attr() 215 | 216 | def it_rejects_more_than_one_argument(): 217 | with expect.raises(ValueError): 218 | decorators.attr(foo=1, bar=2) 219 | 220 | def it_can_be_applied_multiple_times(path): 221 | 222 | @decorators.attr(var1=MockConverter1) 223 | @decorators.attr(var2=MockConverter2) 224 | @decorators.sync(path) 225 | class SampleDecoratedMultiple: 226 | """Class using multiple `attr` decorators.""" 227 | 228 | sample = SampleDecoratedMultiple() 229 | expect(sample.__mapper__.attrs) == {'var1': MockConverter1, 230 | 'var2': MockConverter2} 231 | 232 | def it_can_be_applied_before_sync(path): 233 | 234 | @decorators.attr(var2=MockConverter2) 235 | @decorators.sync(path, attrs={'var1': MockConverter1}) 236 | class SampleDecoratedCombo: 237 | """Class using `attr` decorator and providing a mapping.""" 238 | 239 | sample = SampleDecoratedCombo() 240 | expect(sample.__mapper__.attrs) == {'var1': MockConverter1, 241 | 'var2': MockConverter2} 242 | 243 | def it_can_be_applied_after_sync(path): 244 | 245 | @decorators.sync(path, attrs={'var1': MockConverter1}) 246 | @decorators.attr(var2=MockConverter2) 247 | class SampleDecoratedBackwards: 248 | """Class using `attr` decorator after `sync` decorator.""" 249 | 250 | sample = SampleDecoratedBackwards() 251 | expect(sample.__mapper__.attrs) == {'var1': MockConverter1, 252 | 'var2': MockConverter2} 253 | -------------------------------------------------------------------------------- /yorm/tests/test_diskutils.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,expression-not-assigned,unused-variable 2 | 3 | import os 4 | 5 | import pytest 6 | from expecter import expect 7 | 8 | from yorm import diskutils 9 | 10 | 11 | def describe_touch(): 12 | 13 | @pytest.fixture 14 | def new_path(tmpdir): 15 | tmpdir.chdir() 16 | return os.path.join('.', 'file.ext') 17 | 18 | @pytest.fixture 19 | def new_path_in_directory(): 20 | dirpath = os.path.join('path', 'to', 'directory') 21 | return os.path.join(dirpath, 'file.ext') 22 | 23 | def it_creates_files(new_path): 24 | diskutils.touch(new_path) 25 | expect(os.path.exists(new_path)).is_true() 26 | 27 | def it_can_be_called_twice(new_path): 28 | diskutils.touch(new_path) 29 | diskutils.touch(new_path) 30 | expect(os.path.exists(new_path)).is_true() 31 | 32 | def it_creates_missing_directories(new_path_in_directory): 33 | diskutils.touch(new_path_in_directory) 34 | expect(os.path.exists(new_path_in_directory)).is_true() 35 | 36 | 37 | def describe_delete(): 38 | 39 | @pytest.fixture 40 | def existing_path(tmpdir): 41 | tmpdir.chdir() 42 | path = "tmp/path/to/file.ext" 43 | os.makedirs(os.path.dirname(path)) 44 | open(path, 'w').close() 45 | return path 46 | 47 | @pytest.fixture 48 | def existing_dirpath(tmpdir): 49 | tmpdir.chdir() 50 | dirpath = "tmp/path/to/directory" 51 | os.makedirs(dirpath) 52 | return dirpath 53 | 54 | def it_deletes_existing_files(existing_path): 55 | diskutils.delete(existing_path) 56 | expect(os.path.exists(existing_path)).is_false() 57 | 58 | def it_ignores_missing_files(): 59 | diskutils.delete("tmp/path/to/non/file") 60 | 61 | def it_deletes_directories(existing_dirpath): 62 | diskutils.delete(existing_dirpath) 63 | expect(os.path.exists(existing_dirpath)).is_false() 64 | -------------------------------------------------------------------------------- /yorm/tests/test_mapper.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,redefined-outer-name,unused-variable,expression-not-assigned 2 | 3 | import pytest 4 | from expecter import expect 5 | 6 | import yorm 7 | from yorm import exceptions 8 | from yorm.mapper import Mapper 9 | from yorm.types import Integer 10 | 11 | 12 | class MyObject: 13 | var1 = 1 14 | 15 | 16 | @pytest.fixture 17 | def obj(): 18 | return MyObject() 19 | 20 | 21 | @pytest.fixture 22 | def attrs(): 23 | return {'var2': Integer, 'var3': Integer} 24 | 25 | 26 | @pytest.yield_fixture(params=("real", "fake")) 27 | def mapper(tmpdir, obj, attrs, request): 28 | path = request.param + "/path/to/file" 29 | backup = yorm.settings.fake 30 | if "fake" in path: 31 | yorm.settings.fake = True 32 | elif "real" in path: 33 | tmpdir.chdir() 34 | yield Mapper(obj, path, attrs, auto_track=False) 35 | yorm.settings.fake = backup 36 | 37 | 38 | @pytest.fixture 39 | def mapper_real(tmpdir, obj, attrs): 40 | tmpdir.chdir() 41 | return Mapper(obj, "real/path/to/file", attrs) 42 | 43 | 44 | @pytest.yield_fixture 45 | def mapper_fake(obj, attrs): 46 | backup = yorm.settings.fake 47 | yorm.settings.fake = True 48 | yield Mapper(obj, "fake/path/to/file", attrs) 49 | yorm.settings.fake = backup 50 | 51 | 52 | def describe_mapper(): 53 | 54 | def describe_create(): 55 | 56 | def it_creates_the_file(mapper_real): 57 | mapper_real.create() 58 | 59 | expect(mapper_real.path).exists() 60 | expect(mapper_real.exists).is_true() 61 | expect(mapper_real.deleted).is_false() 62 | 63 | def it_pretends_when_fake(mapper_fake): 64 | mapper_fake.create() 65 | 66 | expect(mapper_fake.path).missing() 67 | expect(mapper_fake.exists).is_true() 68 | expect(mapper_fake.deleted).is_false() 69 | 70 | def it_can_be_called_twice(mapper_real): 71 | mapper_real.create() 72 | mapper_real.create() # should be ignored 73 | 74 | expect(mapper_real.path).exists() 75 | 76 | def describe_delete(): 77 | 78 | def it_deletes_the_file(mapper): 79 | mapper.create() 80 | mapper.delete() 81 | 82 | expect(mapper.path).missing() 83 | expect(mapper.exists).is_false() 84 | expect(mapper.deleted).is_true() 85 | 86 | def it_can_be_called_twice(mapper): 87 | mapper.delete() 88 | mapper.delete() # should be ignored 89 | 90 | expect(mapper.path).missing() 91 | 92 | def describe_load(): 93 | 94 | def it_adds_missing_attributes(obj, mapper): 95 | mapper.create() 96 | mapper.load() 97 | 98 | expect(obj.var1) == 1 99 | expect(obj.var2) == 0 100 | expect(obj.var3) == 0 101 | 102 | def it_ignores_new_attributes(obj, mapper): 103 | mapper.create() 104 | mapper.text = "var4: foo" 105 | 106 | mapper.load() 107 | with expect.raises(AttributeError): 108 | print(obj.var4) 109 | 110 | def it_infers_types_on_new_attributes_with_auto_track(obj, mapper): 111 | mapper.auto_track = True 112 | mapper.create() 113 | mapper.text = "var4: foo" 114 | 115 | mapper.load() 116 | expect(obj.var4) == "foo" 117 | 118 | obj.var4 = 42 119 | mapper.save() 120 | 121 | mapper.load() 122 | expect(obj.var4) == "42" 123 | 124 | def it_raises_an_exception_after_delete(mapper): 125 | mapper.delete() 126 | 127 | with expect.raises(exceptions.DeletedFileError): 128 | mapper.load() 129 | 130 | def describe_modified(): 131 | 132 | def is_true_initially(mapper): 133 | expect(mapper.modified).is_true() 134 | 135 | def is_true_after_create(mapper): 136 | mapper.create() 137 | 138 | expect(mapper.modified).is_true() 139 | 140 | def is_true_after_delete(mapper): 141 | mapper.delete() 142 | 143 | expect(mapper.modified).is_true() 144 | 145 | def is_false_after_load(mapper): 146 | mapper.create() 147 | mapper.load() 148 | 149 | expect(mapper.modified).is_false() 150 | 151 | def can_be_set_false(mapper): 152 | mapper.create() 153 | mapper.modified = False 154 | 155 | expect(mapper.modified).is_false() 156 | 157 | def can_be_set_true(mapper): 158 | mapper.create() 159 | mapper.modified = True 160 | 161 | expect(mapper.modified).is_true() 162 | 163 | def describe_text(): 164 | 165 | def can_get_the_file_contents(obj, mapper): 166 | mapper.create() 167 | obj.var3 = 42 168 | mapper.save() 169 | 170 | expect(mapper.text) == "var2: 0\nvar3: 42\n" 171 | 172 | def can_set_the_file_contents(obj, mapper): 173 | mapper.create() 174 | mapper.text = "var2: 42\n" 175 | mapper.load() 176 | 177 | expect(obj.var2) == 42 178 | 179 | def describe_data(): 180 | 181 | def can_get_the_file_values(obj, mapper): 182 | mapper.create() 183 | obj.var3 = 42 184 | mapper.save() 185 | 186 | expect(mapper.data) == {'var2': 0, 'var3': 42} 187 | 188 | def can_set_the_file_values(obj, mapper): 189 | mapper.create() 190 | mapper.data = {'var2': 42} 191 | mapper.load() 192 | 193 | expect(obj.var2) == 42 194 | 195 | def handles_invalid_content_if_enabled(mapper): 196 | mapper.auto_resolve = True 197 | mapper.create() 198 | mapper.text = "abc" 199 | 200 | expect(mapper.data) == {} 201 | -------------------------------------------------------------------------------- /yorm/tests/test_mixins.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-variable,expression-not-assigned 2 | 3 | from unittest.mock import patch, call 4 | 5 | import pytest 6 | from expecter import expect 7 | 8 | import yorm 9 | from yorm.mixins import ModelMixin 10 | 11 | 12 | def describe_model_mixin(): 13 | 14 | @pytest.fixture 15 | def mixed_class(): 16 | 17 | @yorm.sync("tmp/model.yml") 18 | class MyClass(ModelMixin): 19 | pass 20 | 21 | return MyClass 22 | 23 | @pytest.fixture 24 | def mixed_instance(mixed_class): 25 | return mixed_class() 26 | 27 | @patch('yorm.mixins.utilities') 28 | def it_adds_a_create_method(utilities, mixed_class): 29 | mixed_class.create('foobar', overwrite=True) 30 | 31 | expect(utilities.mock_calls) == [ 32 | call.create(mixed_class, 'foobar', overwrite=True) 33 | ] 34 | 35 | @patch('yorm.mixins.utilities') 36 | def it_adds_a_find_method(utilities, mixed_class): 37 | mixed_class.find('foobar', create=True) 38 | 39 | expect(utilities.mock_calls) == [ 40 | call.find(mixed_class, 'foobar', create=True) 41 | ] 42 | 43 | @patch('yorm.mixins.utilities') 44 | def it_adds_a_match_method(utilities, mixed_class): 45 | mixed_class.match(foo='bar') 46 | 47 | expect(utilities.mock_calls) == [ 48 | call.match(mixed_class, foo='bar') 49 | ] 50 | 51 | @patch('yorm.mixins.utilities') 52 | def it_adds_a_load_method(utilities, mixed_instance): 53 | mixed_instance.load() 54 | 55 | expect(utilities.mock_calls) == [ 56 | call.load(mixed_instance) 57 | ] 58 | 59 | @patch('yorm.mixins.utilities') 60 | def it_adds_a_save_method(utilities, mixed_instance): 61 | mixed_instance.save() 62 | 63 | expect(utilities.mock_calls) == [ 64 | call.save(mixed_instance) 65 | ] 66 | 67 | @patch('yorm.mixins.utilities') 68 | def it_adds_a_delete_method(utilities, mixed_instance): 69 | mixed_instance.delete() 70 | 71 | expect(utilities.mock_calls) == [ 72 | call.delete(mixed_instance) 73 | ] 74 | -------------------------------------------------------------------------------- /yorm/tests/test_types_containers.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,no-self-use,no-member,misplaced-comparison-constant,expression-not-assigned 2 | 3 | import logging 4 | from unittest.mock import patch, Mock 5 | 6 | import pytest 7 | from expecter import expect 8 | 9 | import yorm 10 | from yorm import common 11 | from yorm.decorators import attr 12 | from yorm.types import Dictionary, List 13 | from yorm.types import String, Integer 14 | 15 | from . import strip 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | 20 | # CLASSES ##################################################################### 21 | 22 | 23 | @attr(abc=Integer) 24 | class SampleDictionary(Dictionary): 25 | """Sample dictionary container.""" 26 | 27 | 28 | @attr(var1=Integer) 29 | @attr(var2=String) 30 | class SampleDictionaryWithInitialization(Dictionary): 31 | """Sample dictionary container with initialization.""" 32 | 33 | def __init__(self, var1, var2, var3): 34 | super().__init__() 35 | self.var1 = var1 36 | self.var2 = var2 37 | self.var3 = var3 38 | 39 | 40 | @attr(all=String) 41 | class StringList(List): 42 | """Sample list container.""" 43 | 44 | 45 | class UnknownList(List): 46 | """Sample list container.""" 47 | 48 | 49 | # TESTS ####################################################################### 50 | 51 | 52 | class TestDictionary: 53 | """Unit tests for the `Dictionary` container.""" 54 | 55 | obj = {'abc': 123} 56 | 57 | class SampleClass: 58 | 59 | def __init__(self): 60 | self.abc = 42 61 | 62 | class SampleClass2: 63 | 64 | def __init__(self): 65 | self.unmapped = Mock() 66 | 67 | data_value = [ 68 | (obj, obj), 69 | (None, {'abc': 0}), 70 | ("key=value", {'key': "value", 'abc': 0}), 71 | ("key=", {'key': "", 'abc': 0}), 72 | ("key", {'key': None, 'abc': 0}), 73 | ] 74 | 75 | value_data = [ 76 | (obj, obj), 77 | (SampleClass(), {'abc': 42}), 78 | (SampleClass2(), {'abc': 0}), 79 | ([], {'abc': 0}), 80 | ] 81 | 82 | def setup_method(self, _): 83 | """Reset the class' mapped attributes before each test.""" 84 | common.attrs[SampleDictionary] = {'abc': Integer} 85 | 86 | @pytest.mark.parametrize("data,value", data_value) 87 | def test_to_value(self, data, value): 88 | """Verify input data is converted to values.""" 89 | assert value == SampleDictionary.to_value(data) 90 | 91 | @pytest.mark.parametrize("value,data", value_data) 92 | def test_to_data(self, value, data): 93 | """Verify values are converted to output data.""" 94 | assert data == SampleDictionary.to_data(value) 95 | 96 | def test_not_implemented(self): 97 | """Verify `Dictionary` cannot be used directly.""" 98 | with pytest.raises(NotImplementedError): 99 | Dictionary() 100 | 101 | def test_dict_as_object(self): 102 | """Verify a `Dictionary` can be used as an attribute.""" 103 | dictionary = SampleDictionaryWithInitialization(1, 2, 3.0) 104 | value = {'var1': 1, 'var2': '2'} 105 | value2 = dictionary.to_value(dictionary) 106 | assert value == value2 107 | # keys are not accessible as attributes 108 | assert not hasattr(value2, 'var1') 109 | assert not hasattr(value2, 'var2') 110 | assert not hasattr(value2, 'var3') 111 | 112 | def test_unknown_attrributes_are_ignored(self): 113 | obj = SampleDictionary.create_default() 114 | obj.update_value({'key': "value", 'abc': 7}, auto_track=False) 115 | assert {'abc': 7} == obj 116 | 117 | 118 | class TestList: 119 | """Unit tests for the `List` container.""" 120 | 121 | obj = ["a", "b", "c"] 122 | 123 | data_value = [ 124 | (obj, obj), 125 | (None, []), 126 | ([None], []), 127 | ("a b c", ["a", "b", "c"]), 128 | ("a,b,c", ["a", "b", "c"]), 129 | ("abc", ["abc"]), 130 | ("a\nb\nc", ["a", "b", "c"]), 131 | (4.2, ['4.2']), 132 | (("a", "b"), ["a", "b"]), 133 | ] 134 | 135 | value_data = [ 136 | (obj, obj), 137 | ([], [None]), 138 | ] 139 | 140 | @pytest.mark.parametrize("data,value", data_value) 141 | def test_to_value(self, data, value): 142 | """Verify input data is converted to values.""" 143 | assert value == StringList.to_value(data) 144 | 145 | @pytest.mark.parametrize("value,data", value_data) 146 | def test_to_data(self, value, data): 147 | """Verify values are converted to output data.""" 148 | assert data == StringList.to_data(value) 149 | 150 | def test_item_type(self): 151 | """Verify list item type can be determined.""" 152 | assert String == StringList.item_type 153 | 154 | def test_item_type_none(self): 155 | """Verify list item type defaults to None.""" 156 | assert None is UnknownList.item_type 157 | 158 | def test_not_implemented(self): 159 | """Verify `List` cannot be used directly.""" 160 | with pytest.raises(NotImplementedError): 161 | List() 162 | with pytest.raises(NotImplementedError): 163 | UnknownList() 164 | 165 | def test_shortened_syntax(self): 166 | cls = List.of_type(Integer) 167 | expect(cls.__name__) == "IntegerList" 168 | expect(common.attrs[cls]) == {'all': Integer} 169 | 170 | 171 | class TestExtensions: 172 | """Unit tests for extensions to the container classes.""" 173 | 174 | class FindMixin: 175 | 176 | def find(self, value): 177 | for value2 in self: 178 | if value.lower() == value2.lower(): 179 | return value2 180 | return None 181 | 182 | @yorm.attr(a=yorm.types.String) 183 | class MyDictionary(Dictionary, FindMixin): 184 | pass 185 | 186 | @yorm.attr(all=yorm.types.String) 187 | class MyList(List, FindMixin): 188 | pass 189 | 190 | def test_converted_dict_keeps_type(self): 191 | my_dict = self.MyDictionary() 192 | my_dict['a'] = 1 193 | my_dict2 = self.MyDictionary.to_value(my_dict) 194 | assert 'a' == my_dict2.find('A') 195 | assert None is my_dict2.find('B') 196 | 197 | def test_converted_list_keeps_type(self): 198 | my_list = self.MyList() 199 | my_list.append('a') 200 | my_list2 = self.MyList.to_value(my_list) 201 | assert 'a' == my_list2.find('A') 202 | assert None is my_list2.find('B') 203 | 204 | 205 | @patch('yorm.settings.fake', True) 206 | class TestReservedNames: 207 | 208 | class MyObject: 209 | 210 | def __init__(self, items=None): 211 | self.items = items or [] 212 | 213 | def __repr__(self): 214 | return "" 215 | 216 | def test_list_named_items(self): 217 | my_object = self.MyObject() 218 | yorm.sync_object(my_object, "fake/path", {'items': StringList}) 219 | 220 | log.info("Appending value to list of items...") 221 | my_object.items.append('foo') 222 | 223 | log.info("Checking object contents...") 224 | assert strip(""" 225 | items: 226 | - foo 227 | """) == my_object.__mapper__.text 228 | 229 | log.info("Writing new file contents...") 230 | my_object.__mapper__.text = strip(""" 231 | items: 232 | - bar 233 | """) 234 | 235 | log.info("Checking file contents...") 236 | assert ['bar'] == my_object.items 237 | -------------------------------------------------------------------------------- /yorm/tests/test_types_extended.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,unused-variable,expression-not-assigned,singleton-comparison 2 | 3 | import pytest 4 | from expecter import expect 5 | 6 | from yorm.decorators import attr 7 | from yorm.types.standard import Integer, String, Float 8 | from yorm.types.extended import (NullableString, Number, NullableNumber, 9 | Markdown, AttributeDictionary, SortedList) 10 | 11 | 12 | def describe_nullable_string(): 13 | 14 | def describe_to_value(): 15 | 16 | def it_allows_none(): 17 | expect(NullableString.to_value(None)).is_none() 18 | 19 | def describe_to_data(): 20 | 21 | def it_allows_none(): 22 | expect(NullableString.to_data(None)).is_none() 23 | 24 | 25 | def describe_number(): 26 | 27 | def when_int(): 28 | expect(Number.to_value(42)).isinstance(int) 29 | 30 | def when_float(): 31 | expect(Number.to_value(4.2)).isinstance(float) 32 | 33 | def when_float_lacking_decimal(): 34 | expect(Number.to_value(42)).isinstance(int) 35 | 36 | def when_none(): 37 | expect(Number.to_value(None)).isinstance(int) 38 | 39 | def when_none_and_nullable(): 40 | expect(NullableNumber.to_value(None)) == None 41 | 42 | 43 | def describe_markdown(): 44 | 45 | obj = "This is **the** sentence." 46 | data_value = [ 47 | (obj, obj), 48 | (None, ""), 49 | (['a', 'b', 'c'], "a, b, c"), 50 | ("This is\na sentence.", "This is a sentence."), 51 | ("Sentence one.\nSentence two.", "Sentence one. Sentence two."), 52 | ] 53 | value_data = [ 54 | (obj, obj + '\n'), 55 | ("Sentence one. Sentence two.", "Sentence one.\nSentence two.\n"), 56 | ("", ""), 57 | (" \t ", ""), 58 | ] 59 | 60 | def describe_to_value(): 61 | 62 | @pytest.mark.parametrize("data,value", data_value) 63 | def it_converts_correctly(data, value): 64 | expect(Markdown.to_value(data)) == value 65 | 66 | def describe_to_data(): 67 | 68 | @pytest.mark.parametrize("value,data", value_data) 69 | def it_converts_correctly(value, data): 70 | expect(Markdown.to_data(value)) == data 71 | 72 | 73 | def describe_attribute_dictionary(): 74 | 75 | @pytest.fixture 76 | def cls(): 77 | @attr(var1=Integer) 78 | @attr(var2=String) 79 | class MyAttributeDictionary(AttributeDictionary): pass 80 | return MyAttributeDictionary 81 | 82 | @pytest.fixture 83 | def cls_with_init(): 84 | @attr(var1=Integer) 85 | class MyAttributeDictionary(AttributeDictionary): 86 | def __init__(self, *args, var2="42", **kwargs): 87 | super().__init__(*args, **kwargs) 88 | self.var2 = var2 89 | return MyAttributeDictionary 90 | 91 | @pytest.fixture 92 | def cls_with_args(): 93 | @attr(var1=Integer) 94 | class MyAttributeDictionary(AttributeDictionary): 95 | def __init__(self, var1, var2="42"): 96 | super().__init__() 97 | self.var1 = var1 98 | self.var2 = var2 99 | return MyAttributeDictionary 100 | 101 | def it_cannot_be_used_directly(): 102 | with expect.raises(NotImplementedError): 103 | AttributeDictionary.to_value(None) 104 | with expect.raises(NotImplementedError): 105 | AttributeDictionary.to_data(None) 106 | 107 | def it_has_keys_available_as_attributes(cls): 108 | converter = cls() 109 | 110 | value = converter.to_value({'var1': 1, 'var2': "2"}) 111 | 112 | expect(value.var1) == 1 113 | expect(value.var2) == "2" 114 | 115 | def it_adds_extra_attributes_from_init(cls_with_init): 116 | converter = cls_with_init() 117 | 118 | value = converter.to_value({'var1': 1}) 119 | print(value.__dict__) 120 | 121 | expect(value.var1) == 1 122 | expect(value.var2) == "42" 123 | 124 | def it_allows_positional_arguments(cls_with_args): 125 | converter = cls_with_args(99) 126 | 127 | value = converter.to_value({'var1': 1}) 128 | print(value.__dict__) 129 | 130 | expect(value.var1) == 1 131 | expect(hasattr(value, 'var2')) == False 132 | 133 | 134 | def describe_sorted_list(): 135 | 136 | @attr(all=Float) 137 | class SampleSortedList(SortedList): 138 | """Sample sorted list.""" 139 | 140 | class UnknownSortedList(SortedList): 141 | """Sample list without a type.""" 142 | 143 | @pytest.fixture 144 | def converter(): 145 | return SampleSortedList() 146 | 147 | def it_cannot_be_used_directly(): 148 | with expect.raises(NotImplementedError): 149 | SortedList.to_value(None) 150 | with expect.raises(NotImplementedError): 151 | SortedList.to_data(None) 152 | 153 | def it_cannot_be_subclassed_without_a_type(): 154 | with expect.raises(NotImplementedError): 155 | UnknownSortedList.to_value(None) 156 | with expect.raises(NotImplementedError): 157 | UnknownSortedList.to_data(None) 158 | 159 | def describe_to_data(): 160 | 161 | def it_sorts(converter): 162 | data = converter.to_data([4, 2, 0, 1, 3]) 163 | expect(data) == [0.0, 1.0, 2.0, 3.0, 4.0] 164 | -------------------------------------------------------------------------------- /yorm/tests/test_types_standard.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring,unused-variable,expression-not-assigned 2 | 3 | import pytest 4 | from expecter import expect 5 | 6 | from yorm.types import Object, String, Integer, Float, Boolean 7 | 8 | 9 | def describe_object(): 10 | 11 | obj = None 12 | pairs = [ 13 | (obj, obj), 14 | (None, None), 15 | (1, 1), 16 | (4.2, 4.2), 17 | (['a', 'b', 'c'], ['a', 'b', 'c']), 18 | ] 19 | 20 | def describe_to_value(): 21 | 22 | @pytest.mark.parametrize("first,second", pairs) 23 | def it_converts_correctly(first, second): 24 | expect(Object.to_value(first)) == second 25 | 26 | def describe_to_data(): 27 | 28 | @pytest.mark.parametrize("first,second", pairs) 29 | def it_converts_correctly(first, second): 30 | expect(Object.to_data(first)) == second 31 | 32 | 33 | def describe_string(): 34 | 35 | obj = "Hello, world!" 36 | pairs = [ 37 | (obj, obj), 38 | (None, ""), 39 | ("1.2.3", "1.2.3"), 40 | (['a', 'b', 'c'], "a, b, c"), 41 | ] 42 | pairs_to_value = pairs + [ 43 | (1, "1"), 44 | (4.2, "4.2"), 45 | (False, "false"), 46 | (True, "true"), 47 | ] 48 | pairs_to_data = pairs + [ 49 | (42, 42), 50 | (4.2, 4.2), 51 | ("true", True), 52 | ("false", False), 53 | ] 54 | 55 | def describe_to_value(): 56 | 57 | @pytest.mark.parametrize("first,second", pairs_to_value) 58 | def it_converts_correctly(first, second): 59 | expect(String.to_value(first)) == second 60 | 61 | def describe_to_data(): 62 | 63 | @pytest.mark.parametrize("first,second", pairs_to_data) 64 | def it_converts_correctly(first, second): 65 | expect(String.to_data(first)) == second 66 | 67 | 68 | def describe_integer(): 69 | 70 | obj = 42 71 | pairs = [ 72 | (obj, obj), 73 | (None, 0), 74 | ("1", 1), 75 | ("1.1", 1), 76 | (True, 1), 77 | (False, 0), 78 | ] 79 | 80 | def describe_to_value(): 81 | 82 | @pytest.mark.parametrize("first,second", pairs) 83 | def it_converts_correctly(first, second): 84 | expect(Integer.to_value(first)) == second 85 | 86 | def it_raises_an_exception_when_unable_to_convert(): 87 | with expect.raises(ValueError): 88 | Integer.to_value("abc") 89 | 90 | def describe_to_data(): 91 | 92 | @pytest.mark.parametrize("first,second", pairs) 93 | def it_converts_correctly(first, second): 94 | expect(Integer.to_data(first)) == second 95 | 96 | 97 | def describe_float(): 98 | 99 | obj = 4.2 100 | pairs = [ 101 | (obj, obj), 102 | (None, 0.0), 103 | ("1.0", 1.0), 104 | ("1.1", 1.1), 105 | ] 106 | 107 | def describe_to_value(): 108 | 109 | @pytest.mark.parametrize("first,second", pairs) 110 | def it_converts_correctly(first, second): 111 | expect(Float.to_value(first)) == second 112 | 113 | def it_raises_an_exception_when_unable_to_convert(): 114 | with expect.raises(ValueError): 115 | Float.to_value("abc") 116 | 117 | def describe_to_data(): 118 | 119 | @pytest.mark.parametrize("first,second", pairs) 120 | def it_converts_correctly(first, second): 121 | expect(Float.to_data(first)) == second 122 | 123 | 124 | def describe_boolean(): 125 | 126 | obj = True 127 | pairs = [ 128 | (obj, obj), 129 | (None, False), 130 | (0, False), 131 | (1, True), 132 | ("", False), 133 | ("True", True), 134 | ("False", False), 135 | ("true", True), 136 | ("false", False), 137 | ("T", True), 138 | ("F", False), 139 | ("yes", True), 140 | ("no", False), 141 | ("Y", True), 142 | ("N", False), 143 | ("enabled", True), 144 | ("disabled", False), 145 | ("on", True), 146 | ("off", False), 147 | ("Hello, world!", True) 148 | ] 149 | 150 | def describe_to_value(): 151 | 152 | @pytest.mark.parametrize("first,second", pairs) 153 | def it_converts_correctly(first, second): 154 | expect(Boolean.to_value(first)) == second 155 | 156 | def describe_to_data(): 157 | 158 | @pytest.mark.parametrize("first,second", pairs) 159 | def it_converts_correctly(first, second): 160 | expect(Boolean.to_data(first)) == second 161 | -------------------------------------------------------------------------------- /yorm/tests/test_utilities.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-variable,redefined-outer-name,expression-not-assigned,singleton-comparison 2 | 3 | import logging 4 | from unittest.mock import Mock 5 | 6 | import pytest 7 | from expecter import expect 8 | 9 | import yorm 10 | from yorm import exceptions 11 | from yorm import utilities 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | @pytest.fixture 17 | def model_class(tmpdir): 18 | tmpdir.chdir() 19 | 20 | @yorm.sync("data/{self.kind}/{self.key}.yml", auto_create=False) 21 | class Model: 22 | 23 | def __init__(self, kind, key, **kwargs): 24 | self.kind = kind 25 | self.key = key 26 | assert 0 <= len(kwargs) < 2 27 | if kwargs: 28 | assert kwargs == {'test': 'test'} 29 | 30 | def __eq__(self, other): 31 | return (self.kind, self.key) == (other.kind, other.key) 32 | 33 | return Model 34 | 35 | 36 | @pytest.fixture 37 | def instance(model_class): 38 | return model_class('foo', 'bar') 39 | 40 | 41 | @pytest.fixture 42 | def instances(model_class): 43 | instances = [ 44 | model_class(kind, key) 45 | for kind in ('spam', 'egg') 46 | for key in ('foo', 'bar') 47 | ] 48 | for instance in instances: 49 | instance.__mapper__.create() 50 | 51 | return instances 52 | 53 | 54 | def describe_create(): 55 | 56 | def it_creates_files(model_class): 57 | instance = utilities.create(model_class, 'foo', 'bar') 58 | 59 | expect(instance.__mapper__.exists) == True 60 | expect(instance.__mapper__.modified) == False 61 | 62 | def it_requires_files_to_not_yet_exist(model_class, instance): 63 | instance.__mapper__.create() 64 | 65 | with expect.raises(exceptions.DuplicateMappingError): 66 | utilities.create(model_class, 'foo', 'bar') 67 | 68 | def it_can_overwrite_files(model_class, instance): 69 | instance.__mapper__.create() 70 | 71 | utilities.create(model_class, 'foo', 'bar', overwrite=True) 72 | 73 | def it_supports_keyword_arguments(model_class): 74 | instance = utilities.create(model_class, 'foo', key='bar') 75 | 76 | expect(instance.__mapper__.exists) == True 77 | 78 | def it_can_also_be_called_with_an_instance(instance): 79 | expect(yorm.create(instance)) == instance 80 | 81 | def it_requires_a_mapped_class_or_instance(): 82 | with expect.raises(TypeError): 83 | utilities.create(Mock) 84 | 85 | 86 | def describe_find(): 87 | 88 | def it_returns_object_when_found(model_class, instance): 89 | instance.__mapper__.create() 90 | 91 | expect(utilities.find(model_class, 'foo', 'bar')) == instance 92 | 93 | def it_returns_none_when_no_match(model_class): 94 | expect(utilities.find(model_class, 'not', 'here')) == None 95 | 96 | def it_allows_objects_to_be_created(model_class): 97 | expect(utilities.find(model_class, 'new', 'one', create=True)) == \ 98 | model_class('new', 'one') 99 | 100 | def it_supports_keyword_arguments(model_class, instance): 101 | instance.__mapper__.create() 102 | 103 | expect(utilities.find(model_class, 'foo', key='bar')) == instance 104 | 105 | def it_can_also_be_called_with_an_instance(instance): 106 | expect(yorm.find(instance, create=True)) == instance 107 | 108 | def it_requires_a_mapped_class_or_instance(): 109 | with expect.raises(TypeError): 110 | utilities.find(Mock) 111 | 112 | 113 | def describe_match(): 114 | 115 | def with_class_and_factory(model_class, instances): 116 | matches = list( 117 | utilities.match( 118 | model_class, 119 | (lambda key, kind: model_class(kind, key, test="test")), 120 | kind='spam', 121 | key='foo', 122 | ) 123 | ) 124 | expect(len(matches)) == 1 125 | instance = matches[0] 126 | expect(instance.kind) == 'spam' 127 | expect(instance.key) == 'foo' 128 | expect(instances).contains(instance) 129 | 130 | def with_class_and_no_factory(model_class, instances): 131 | matches = list( 132 | utilities.match( 133 | model_class, 134 | kind='spam', 135 | key='foo', 136 | ) 137 | ) 138 | expect(len(matches)) == 1 139 | instance = matches[0] 140 | expect(instance.kind) == 'spam' 141 | expect(instance.key) == 'foo' 142 | expect(instances).contains(instance) 143 | 144 | def with_string(model_class, instances): 145 | matches = list( 146 | utilities.match( 147 | "data/{kind}/{key}.yml", 148 | (lambda key, kind: model_class(kind, key, test="test")), 149 | kind='egg', 150 | key='foo', 151 | ) 152 | ) 153 | expect(len(matches)) == 1 154 | instance = matches[0] 155 | expect(instance.kind) == 'egg' 156 | expect(instance.key) == 'foo' 157 | expect(instances).contains(instance) 158 | 159 | def with_self_string(model_class, instances): 160 | matches = list( 161 | utilities.match( 162 | "data/{self.kind}/{self.key}.yml", 163 | (lambda key, kind: model_class(kind, key, test="test")), 164 | kind='spam', 165 | key='bar', 166 | ) 167 | ) 168 | expect(len(matches)) == 1 169 | instance = matches[0] 170 | expect(instance.kind) == 'spam' 171 | expect(instance.key) == 'bar' 172 | expect(instances).contains(instance) 173 | 174 | def with_class_and_partial_match(model_class, instances): 175 | matches = list( 176 | utilities.match( 177 | model_class, 178 | kind='spam', 179 | ) 180 | ) 181 | expect(len(matches)) == 2 182 | for instance in matches: 183 | expect(instance.kind) == 'spam' 184 | expect(instances).contains(instance) 185 | 186 | def with_string_and_partial_match(model_class, instances): 187 | matches = list( 188 | utilities.match( 189 | "data/{kind}/{key}.yml", 190 | (lambda key, kind: model_class(kind, key, test="test")), 191 | key='foo', 192 | ) 193 | ) 194 | expect(len(matches)) == 2 195 | for instance in matches: 196 | expect(instance.key) == 'foo' 197 | expect(instances).contains(instance) 198 | 199 | def with_self_string_and_partial_match(model_class, instances): 200 | matches = list( 201 | utilities.match( 202 | "data/{self.kind}/{self.key}.yml", 203 | (lambda key, kind: model_class(kind, key, test="test")), 204 | kind='egg', 205 | ) 206 | ) 207 | expect(len(matches)) == 2 208 | for instance in matches: 209 | expect(instance.kind) == 'egg' 210 | expect(instances).contains(instance) 211 | 212 | 213 | def describe_load(): 214 | 215 | def it_marks_files_as_unmodified(instance): 216 | instance.__mapper__.create() 217 | instance.__mapper__.modified = True 218 | 219 | utilities.load(instance) 220 | 221 | expect(instance.__mapper__.modified) == False 222 | 223 | def it_requires_a_mapped_instance(): 224 | with expect.raises(TypeError): 225 | utilities.load(Mock) 226 | 227 | 228 | def describe_save(): 229 | 230 | def it_creates_files(instance): 231 | utilities.save(instance) 232 | 233 | expect(instance.__mapper__.exists) == True 234 | 235 | def it_marks_files_as_modified(instance): 236 | instance.__mapper__.create() 237 | instance.__mapper__.modified = False 238 | 239 | utilities.save(instance) 240 | 241 | expect(instance.__mapper__.modified) == True 242 | 243 | def it_expects_the_file_to_not_be_deleted(instance): 244 | instance.__mapper__.delete() 245 | 246 | with expect.raises(exceptions.DeletedFileError): 247 | utilities.save(instance) 248 | 249 | def it_requires_a_mapped_instance(): 250 | with expect.raises(TypeError): 251 | utilities.save(Mock) 252 | 253 | 254 | def describe_delete(): 255 | 256 | def it_deletes_files(instance): 257 | utilities.delete(instance) 258 | 259 | expect(instance.__mapper__.exists) == False 260 | expect(instance.__mapper__.deleted) == True 261 | 262 | def it_requires_a_mapped_instance(): 263 | with expect.raises(TypeError): 264 | utilities.delete(Mock) 265 | -------------------------------------------------------------------------------- /yorm/types/__init__.py: -------------------------------------------------------------------------------- 1 | """Converters for attributes.""" 2 | 3 | # pylint: disable=wildcard-import 4 | 5 | from .standard import * 6 | from .containers import * 7 | from .extended import * 8 | -------------------------------------------------------------------------------- /yorm/types/_representers.py: -------------------------------------------------------------------------------- 1 | """Custom YAML representers.""" 2 | 3 | from collections import OrderedDict 4 | 5 | import yaml 6 | 7 | 8 | class LiteralString(str): 9 | """Custom type for strings which should be dumped in the literal style.""" 10 | 11 | 12 | def represent_none(self, _): 13 | return self.represent_scalar('tag:yaml.org,2002:null', '') 14 | 15 | 16 | def represent_literalstring(dumper, data): 17 | return dumper.represent_scalar('tag:yaml.org,2002:str', data, 18 | style='|' if data else '') 19 | 20 | 21 | def represent_ordereddict(dumper, data): 22 | value = [] 23 | 24 | for item_key, item_value in data.items(): 25 | node_key = dumper.represent_data(item_key) 26 | node_value = dumper.represent_data(item_value) 27 | 28 | value.append((node_key, node_value)) 29 | 30 | return yaml.nodes.MappingNode('tag:yaml.org,2002:map', value) 31 | 32 | 33 | yaml.add_representer(LiteralString, represent_literalstring) 34 | yaml.add_representer(OrderedDict, represent_ordereddict) 35 | yaml.add_representer(type(None), represent_none) 36 | -------------------------------------------------------------------------------- /yorm/types/containers.py: -------------------------------------------------------------------------------- 1 | """Converter classes for builtin container types.""" 2 | 3 | import logging 4 | 5 | from .. import common 6 | from ..bases import Container 7 | from . import standard 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | class Dictionary(Container, dict): 13 | """Base class for a dictionary of attribute types.""" 14 | 15 | def __new__(cls, *args, **kwargs): 16 | if cls is Dictionary: 17 | msg = "Dictionary class must be subclassed to use" 18 | raise NotImplementedError(msg) 19 | return super().__new__(cls, *args, **kwargs) 20 | 21 | @classmethod 22 | def to_data(cls, value): 23 | value2 = cls.create_default() 24 | value2.update_value(value, auto_track=False) 25 | 26 | data = common.attrs[cls].__class__() 27 | for name, converter in common.attrs[cls].items(): 28 | data[name] = converter.to_data(value2.get(name, None)) 29 | 30 | return data 31 | 32 | def update_value(self, data, *, auto_track=True): 33 | cls = self.__class__ 34 | value = cls.create_default() 35 | 36 | # Convert object attributes to a dictionary 37 | attrs = common.attrs[cls].copy() 38 | if isinstance(data, cls): 39 | dictionary = {} 40 | for k, v in data.items(): 41 | if k in attrs: 42 | dictionary[k] = v 43 | for k, v in data.__dict__.items(): 44 | if k in attrs: 45 | dictionary[k] = v 46 | else: 47 | dictionary = to_dict(data) 48 | 49 | # Map object attributes to types 50 | for name, data2 in dictionary.items(): 51 | 52 | try: 53 | converter = attrs.pop(name) 54 | except KeyError: 55 | if auto_track: 56 | converter = standard.match(name, data2, nested=True) 57 | common.attrs[cls][name] = converter 58 | else: 59 | msg = "Ignored unknown nested file attribute: %s = %r" 60 | log.warning(msg, name, data2) 61 | continue 62 | 63 | try: 64 | attr = self[name] 65 | except KeyError: 66 | attr = converter.create_default() 67 | 68 | if all((isinstance(attr, converter), 69 | issubclass(converter, Container))): 70 | attr.update_value(data2, auto_track=auto_track) 71 | else: 72 | attr = converter.to_value(data2) 73 | 74 | value[name] = attr 75 | 76 | # Create default values for unmapped types 77 | for name, converter in attrs.items(): 78 | value[name] = converter.create_default() 79 | msg = "Default value for missing nested object attribute: %s = %r" 80 | log.info(msg, name, value[name]) 81 | 82 | # Execute custom initialization validators 83 | try: 84 | cls(**value) 85 | except TypeError as exception: 86 | log.warning("%s: %s", cls.__name__, exception) 87 | 88 | # Apply the new value 89 | self.clear() 90 | self.update(value) 91 | 92 | 93 | class List(Container, list): 94 | """Base class for a homogeneous list of attribute types.""" 95 | 96 | def __new__(cls, *args, **kwargs): 97 | if cls is List: 98 | raise NotImplementedError("List class must be subclassed to use") 99 | if not cls.item_type: 100 | raise NotImplementedError("List subclass must specify item type") 101 | return super().__new__(cls, *args, **kwargs) 102 | 103 | @classmethod 104 | def of_type(cls, sub_class): 105 | name = sub_class.__name__ + cls.__name__ 106 | new_class = type(name, (cls,), {}) 107 | common.attrs[new_class][common.ALL] = sub_class 108 | return new_class 109 | 110 | @common.classproperty 111 | def item_type(cls): # pylint: disable=no-self-argument 112 | """Get the converter class for all items.""" 113 | return common.attrs[cls].get(common.ALL) 114 | 115 | @classmethod 116 | def to_data(cls, value): 117 | value2 = cls.create_default() 118 | value2.update_value(value, auto_track=False) 119 | 120 | data = [] 121 | 122 | if value2: 123 | for item in value2: 124 | data.append(cls.item_type.to_data(item)) # pylint: disable=no-member 125 | 126 | if not data: 127 | data.append(None) 128 | 129 | return data 130 | 131 | def update_value(self, data, *, auto_track=True): 132 | cls = self.__class__ 133 | value = cls.create_default() 134 | 135 | # Get the converter for all items 136 | converter = cls.item_type 137 | 138 | # Convert the parsed data 139 | for item in to_list(data): 140 | 141 | if item is None: 142 | continue 143 | 144 | try: 145 | attr = self[len(value)] 146 | except IndexError: 147 | attr = converter.create_default() # pylint: disable=no-member 148 | 149 | if all((isinstance(attr, converter), 150 | issubclass(converter, Container))): 151 | attr.update_value(item, auto_track=auto_track) 152 | else: 153 | attr = converter.to_value(item) # pylint: disable=no-member 154 | 155 | value.append(attr) 156 | 157 | # Apply the new value 158 | self[:] = value[:] 159 | 160 | 161 | def to_dict(obj): 162 | """Convert a dictionary-like object to a dictionary. 163 | 164 | >>> to_dict({'key': 42}) 165 | {'key': 42} 166 | 167 | >>> to_dict("key=42") 168 | {'key': '42'} 169 | 170 | >>> to_dict("key") 171 | {'key': None} 172 | 173 | >>> to_dict(None) 174 | {} 175 | 176 | """ 177 | if isinstance(obj, dict): 178 | return obj 179 | elif isinstance(obj, str): 180 | text = obj.strip() 181 | parts = text.split('=') 182 | if len(parts) == 2: 183 | return {parts[0]: parts[1]} 184 | else: 185 | return {text: None} 186 | else: 187 | try: 188 | return obj.__dict__ 189 | except AttributeError: 190 | return {} 191 | 192 | 193 | def to_list(obj): 194 | """Convert a list-like object to a list. 195 | 196 | >>> to_list([1, 2, 3]) 197 | [1, 2, 3] 198 | 199 | >>> to_list("a,b,c") 200 | ['a', 'b', 'c'] 201 | 202 | >>> to_list("item") 203 | ['item'] 204 | 205 | >>> to_list(None) 206 | [] 207 | 208 | """ 209 | if isinstance(obj, (list, tuple)): 210 | return obj 211 | elif isinstance(obj, str): 212 | text = obj.strip() 213 | if ',' in text and ' ' not in text: 214 | return text.split(',') 215 | else: 216 | return text.split() 217 | elif obj is not None: 218 | return [obj] 219 | else: 220 | return [] 221 | -------------------------------------------------------------------------------- /yorm/types/extended.py: -------------------------------------------------------------------------------- 1 | """Converter classes for extensions to builtin types.""" 2 | 3 | import re 4 | import logging 5 | 6 | from .standard import String, Integer, Float, Boolean 7 | from .containers import Dictionary, List 8 | from ._representers import LiteralString 9 | 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | # NULLABLE BUILTINS ########################################################### 15 | 16 | 17 | class NullableString(String): 18 | """Converter for the `str` type with `None` as default.""" 19 | 20 | DEFAULT = None 21 | 22 | 23 | class NullableInteger(Integer): 24 | """Converter for the `int` type with `None` as default.""" 25 | 26 | DEFAULT = None 27 | 28 | 29 | class NullableFloat(Float): 30 | """Converter for the `float` type with `None` as default.""" 31 | 32 | DEFAULT = None 33 | 34 | 35 | class NullableBoolean(Boolean): 36 | """Converter for the `bool` type with `None` as default.""" 37 | 38 | DEFAULT = None 39 | 40 | 41 | # CUSTOM TYPES ################################################################ 42 | 43 | 44 | class Number(Float): 45 | 46 | DEFAULT = 0 47 | 48 | @classmethod 49 | def to_value(cls, obj): 50 | value = super().to_value(obj) 51 | if value and int(value) == value: 52 | return int(value) 53 | return value 54 | 55 | 56 | class NullableNumber(Number): 57 | 58 | DEFAULT = None 59 | 60 | 61 | class Markdown(String): 62 | """Converter for a `str` type that contains Markdown.""" 63 | 64 | REGEX_MARKDOWN_SPACES = re.compile(r""" 65 | 66 | ([^\n ]) # any character but a newline or space 67 | 68 | (\ ?\n) # optional space + single newline 69 | 70 | (?! # none of the following: 71 | 72 | (?:\s) # whitespace 73 | | 74 | (?:[-+*]\s) # unordered list separator + whitespace 75 | | 76 | (?:\d+\.\s) # number + period + whitespace 77 | 78 | ) 79 | 80 | ([^\n]) # any character but a newline 81 | 82 | """, re.VERBOSE | re.IGNORECASE) 83 | 84 | # based on: http://en.wikipedia.org/wiki/Sentence_boundary_disambiguation 85 | REGEX_SENTENCE_BOUNDARIES = re.compile(r""" 86 | 87 | ( # one of the following: 88 | 89 | (?<=[a-z)][.?!]) # lowercase letter + punctuation 90 | | 91 | (?<=[a-z0-9][.?!]\") # lowercase letter/number + punctuation + quote 92 | 93 | ) 94 | 95 | (\s) # any whitespace 96 | 97 | (?=\"?[A-Z]) # optional quote + an upppercase letter 98 | 99 | """, re.VERBOSE) 100 | 101 | @classmethod 102 | def to_value(cls, obj): 103 | """Join non-meaningful line breaks.""" 104 | value = String.to_value(obj) 105 | return cls._join(value) 106 | 107 | @classmethod 108 | def to_data(cls, obj): 109 | """Break a string at sentences and dump as a literal string.""" 110 | value = String.to_value(obj) 111 | data = String.to_data(value) 112 | split = cls._split(data) 113 | return LiteralString(split) 114 | 115 | @classmethod 116 | def _join(cls, text): 117 | r"""Convert single newlines (ignored by Markdown) to spaces. 118 | 119 | >>> Markdown._join("abc\n123") 120 | 'abc 123' 121 | 122 | >>> Markdown._join("abc\n\n123") 123 | 'abc\n\n123' 124 | 125 | >>> Markdown._join("abc \n123") 126 | 'abc 123' 127 | 128 | """ 129 | return cls.REGEX_MARKDOWN_SPACES.sub(r'\1 \3', text).strip() 130 | 131 | @classmethod 132 | def _split(cls, text, end='\n'): 133 | r"""Replace sentence boundaries with newlines and append a newline. 134 | 135 | :param text: string to line break at sentences 136 | :param end: appended to the end of the update text 137 | 138 | >>> Markdown._split("Hello, world!", end='') 139 | 'Hello, world!' 140 | 141 | >>> Markdown._split("Hello, world! How are you? I'm fine. Good.") 142 | "Hello, world!\nHow are you?\nI'm fine.\nGood.\n" 143 | 144 | """ 145 | stripped = text.strip() 146 | if stripped: 147 | return cls.REGEX_SENTENCE_BOUNDARIES.sub('\n', stripped) + end 148 | else: 149 | return '' 150 | 151 | 152 | # CUSTOM CONTAINERS ########################################################### 153 | 154 | 155 | class AttributeDictionary(Dictionary): 156 | """Dictionary converter with keys available as attributes.""" 157 | 158 | def __init__(self, *args, **kwargs): 159 | super().__init__(*args, **kwargs) 160 | self.__dict__ = self 161 | 162 | @classmethod 163 | def create_default(cls): 164 | """Create an uninitialized object with keys as attributes.""" 165 | if cls is AttributeDictionary: 166 | msg = "AttributeDictionary class must be subclassed to use" 167 | raise NotImplementedError(msg) 168 | 169 | try: 170 | obj = cls() 171 | except TypeError as exc: 172 | log.info("Default values in %s are not available when " 173 | "positional arguments are used: %s", cls.__name__, exc) 174 | obj = cls.__new__(cls) 175 | obj.__dict__ = obj 176 | 177 | return obj 178 | 179 | 180 | class SortedList(List): 181 | """List converter that is sorted on disk.""" 182 | 183 | @classmethod 184 | def create_default(cls): 185 | """Create an uninitialized object.""" 186 | if cls is SortedList: 187 | msg = "SortedList class must be subclassed to use" 188 | raise NotImplementedError(msg) 189 | if not cls.item_type: 190 | msg = "SortedList subclass must specify item type" 191 | raise NotImplementedError(msg) 192 | 193 | return cls.__new__(cls) 194 | 195 | @classmethod 196 | def to_data(cls, obj): 197 | """Convert all attribute values for optimal dumping to YAML.""" 198 | value = cls.to_value(obj) 199 | 200 | data = [] 201 | 202 | for item in sorted(value): 203 | data.append(cls.item_type.to_data(item)) # pylint: disable=no-member 204 | 205 | return data 206 | -------------------------------------------------------------------------------- /yorm/types/standard.py: -------------------------------------------------------------------------------- 1 | """Convertible classes for builtin immutable types.""" 2 | 3 | import logging 4 | 5 | from .. import exceptions 6 | from ..bases import Converter 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | class Object(Converter): 12 | """Base class for immutable types.""" 13 | 14 | TYPE = None # type for inferred types (set in subclasses) 15 | DEFAULT = None # default value for conversion (set in subclasses) 16 | 17 | @classmethod 18 | def create_default(cls): 19 | return cls.DEFAULT 20 | 21 | @classmethod 22 | def to_value(cls, obj): 23 | return obj 24 | 25 | @classmethod 26 | def to_data(cls, obj): 27 | return cls.to_value(obj) 28 | 29 | 30 | class String(Object): 31 | """Convertible for the `str` type.""" 32 | 33 | TYPE = str 34 | DEFAULT = "" 35 | 36 | @classmethod 37 | def to_value(cls, obj): 38 | if isinstance(obj, cls.TYPE): 39 | return obj 40 | elif obj is True: 41 | return "true" 42 | elif obj is False: 43 | return "false" 44 | elif obj: 45 | try: 46 | return ', '.join(str(item) for item in obj) 47 | except TypeError: 48 | return str(obj) 49 | else: 50 | return cls.DEFAULT 51 | 52 | @classmethod 53 | def to_data(cls, obj): 54 | value = cls.to_value(obj) 55 | return cls._optimize_for_quoting(value) 56 | 57 | @staticmethod 58 | def _optimize_for_quoting(value): 59 | if value == "true": 60 | return True 61 | if value == "false": 62 | return False 63 | for number_type in (int, float): 64 | try: 65 | return number_type(value) 66 | except (TypeError, ValueError): 67 | continue 68 | return value 69 | 70 | 71 | class Integer(Object): 72 | """Convertible for the `int` type.""" 73 | 74 | TYPE = int 75 | DEFAULT = 0 76 | 77 | @classmethod 78 | def to_value(cls, obj): 79 | if all((isinstance(obj, cls.TYPE), 80 | obj is not True, 81 | obj is not False)): 82 | return obj 83 | elif obj: 84 | try: 85 | return int(obj) 86 | except ValueError: 87 | return int(float(obj)) 88 | else: 89 | return cls.DEFAULT 90 | 91 | 92 | class Float(Object): 93 | """Convertible for the `float` type.""" 94 | 95 | TYPE = float 96 | DEFAULT = 0.0 97 | 98 | @classmethod 99 | def to_value(cls, obj): 100 | if isinstance(obj, cls.TYPE): 101 | return obj 102 | elif obj: 103 | return float(obj) 104 | else: 105 | return cls.DEFAULT 106 | 107 | 108 | class Boolean(Object): 109 | """Convertible for the `bool` type.""" 110 | 111 | TYPE = bool 112 | DEFAULT = False 113 | 114 | FALSY = ('false', 'f', 'no', 'n', 'disabled', 'off', '0') 115 | 116 | @classmethod 117 | def to_value(cls, obj): 118 | if isinstance(obj, str) and obj.lower().strip() in cls.FALSY: 119 | return False 120 | elif obj is not None: 121 | return bool(obj) 122 | else: 123 | return cls.DEFAULT 124 | 125 | 126 | def match(name, data, nested=False): 127 | """Determine the appropriate converter for new data.""" 128 | nested = " nested" if nested else "" 129 | msg = "Determining converter for new%s: '%s' = %r" 130 | log.debug(msg, nested, name, repr(data)) 131 | 132 | types = Object.__subclasses__() # pylint: disable=no-member 133 | log.trace("Converter options: {}".format(types)) 134 | 135 | for converter in types: 136 | if converter.TYPE and type(data) == converter.TYPE: # pylint: disable=unidiomatic-typecheck 137 | log.debug("Matched converter: %s", converter) 138 | log.info("New%s attribute: %s", nested, name) 139 | return converter 140 | 141 | if data is None or isinstance(data, (dict, list)): 142 | log.info("Default converter: %s", Object) 143 | log.warning("New%s attribute with unknown type: %s", nested, name) 144 | return Object 145 | 146 | msg = "No converter available for: {}".format(data) 147 | raise exceptions.FileContentError(msg) 148 | -------------------------------------------------------------------------------- /yorm/utilities.py: -------------------------------------------------------------------------------- 1 | """Functions to interact with mapped classes and instances.""" 2 | 3 | import inspect 4 | import logging 5 | import string 6 | import glob 7 | import types 8 | 9 | import parse 10 | 11 | from . import common, exceptions 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | def create(class_or_instance, *args, overwrite=False, **kwargs): 17 | """Create a new mapped object. 18 | 19 | NOTE: Calling this function is unnecessary with 'auto_create' enabled. 20 | 21 | """ 22 | instance = _instantiate(class_or_instance, *args, **kwargs) 23 | mapper = common.get_mapper(instance, expected=True) 24 | 25 | if mapper.exists and not overwrite: 26 | msg = "{!r} already exists".format(mapper.path) 27 | raise exceptions.DuplicateMappingError(msg) 28 | 29 | return load(save(instance)) 30 | 31 | 32 | def find(class_or_instance, *args, create=False, **kwargs): # pylint: disable=redefined-outer-name 33 | """Find a matching mapped object or return None.""" 34 | instance = _instantiate(class_or_instance, *args, **kwargs) 35 | mapper = common.get_mapper(instance, expected=True) 36 | 37 | if mapper.exists: 38 | return instance 39 | elif create: 40 | return save(instance) 41 | else: 42 | return None 43 | 44 | 45 | class GlobFormatter(string.Formatter): 46 | """Uses '*' for all unknown fields.""" 47 | 48 | WILDCARD = object() 49 | 50 | def get_field(self, field_name, args, kwargs): 51 | try: 52 | return super().get_field(field_name, args, kwargs) 53 | except (KeyError, IndexError, AttributeError): 54 | return self.WILDCARD, None 55 | 56 | def get_value(self, key, args, kwargs): 57 | try: 58 | return super().get_value(key, args, kwargs) 59 | except (KeyError, IndexError, AttributeError): 60 | return self.WILDCARD 61 | 62 | def convert_field(self, value, conversion): 63 | if value is self.WILDCARD: 64 | return self.WILDCARD 65 | else: 66 | return super().convert_field(value, conversion) 67 | 68 | def format_field(self, value, format_spec): 69 | if value is self.WILDCARD: 70 | return '*' 71 | else: 72 | return super().format_field(value, format_spec) 73 | 74 | 75 | def _unpack_parsed_fields(pathfields): 76 | return { 77 | (k[len('self.'):] if k.startswith('self.') else k): v 78 | for k, v in pathfields.items() 79 | } 80 | 81 | 82 | def match(cls_or_path, _factory=None, **kwargs): 83 | """Yield all matching mapped objects. 84 | 85 | Can be used two ways: 86 | 87 | * With a YORM-decorated class, optionally with a factory callable 88 | * With a Python 3-style string template and a factory callable 89 | 90 | The factory callable must accept keyword arguments, extracted from the file 91 | name merged with those passed to match(). If no factory is given, the class 92 | itself is used as the factory (same signature). 93 | 94 | Keyword arguments are used to filter objects. Filtering is only done by 95 | filename, so only fields that are part of the path_format can be filtered 96 | against. 97 | 98 | """ 99 | if isinstance(cls_or_path, type): 100 | path_format = common.path_formats[cls_or_path] 101 | # Let KeyError fail through 102 | if _factory is None: 103 | _factory = cls_or_path 104 | else: 105 | path_format = cls_or_path 106 | if _factory is None: 107 | raise TypeError("Factory must be given if a path format is given") 108 | 109 | gf = GlobFormatter() 110 | mock = types.SimpleNamespace(**kwargs) 111 | 112 | kwargs['self'] = mock 113 | posix_pattern = gf.vformat(path_format, (), kwargs.copy()) 114 | del kwargs['self'] 115 | py_pattern = parse.compile(path_format) 116 | 117 | for filename in glob.iglob(posix_pattern): 118 | pathfields = py_pattern.parse(filename).named 119 | fields = _unpack_parsed_fields(pathfields) 120 | fields.update(kwargs) 121 | yield _factory(**fields) 122 | 123 | 124 | def load(instance): 125 | """Force the loading of a mapped object's file. 126 | 127 | NOTE: Calling this function is unnecessary. It exists for the 128 | aesthetic purpose of having symmetry between save and load. 129 | 130 | """ 131 | mapper = common.get_mapper(instance, expected=True) 132 | 133 | mapper.load() 134 | 135 | return instance 136 | 137 | 138 | def save(instance): 139 | """Save a mapped object to file. 140 | 141 | NOTE: Calling this function is unnecessary with 'auto_save' enabled. 142 | 143 | """ 144 | mapper = common.get_mapper(instance, expected=True) 145 | 146 | if mapper.deleted: 147 | msg = "{!r} was deleted".format(mapper.path) 148 | raise exceptions.DeletedFileError(msg) 149 | 150 | if not mapper.exists: 151 | mapper.create() 152 | 153 | mapper.save() 154 | 155 | return instance 156 | 157 | 158 | def delete(instance): 159 | """Delete a mapped object's file.""" 160 | mapper = common.get_mapper(instance, expected=True) 161 | 162 | mapper.delete() 163 | 164 | 165 | def _instantiate(class_or_instance, *args, **kwargs): 166 | if inspect.isclass(class_or_instance): 167 | instance = class_or_instance(*args, **kwargs) 168 | else: 169 | assert not args 170 | instance = class_or_instance 171 | 172 | return instance 173 | --------------------------------------------------------------------------------