├── .editorconfig ├── .github ├── CONTRIBUTING.rst └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .isort.cfg ├── .pylintrc ├── .readthedocs.yml ├── .travis.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.rst ├── docs ├── Makefile ├── _static │ ├── forge-horizontal.png │ ├── forge-horizontal.svg │ ├── forge-vertical.png │ └── forge-vertical.svg ├── api.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── docutils.conf ├── glossary.rst ├── index.rst ├── license.rst ├── patterns.rst ├── philosophy.rst ├── revision.rst └── signature.rst ├── forge ├── __init__.py ├── _config.py ├── _counter.py ├── _exceptions.py ├── _immutable.py ├── _marker.py ├── _revision.py ├── _signature.py └── _utils.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── test__module__.py ├── test_config.py ├── test_counter.py ├── test_immutable.py ├── test_marker.py ├── test_revision.py ├── test_signature.py └── test_utils.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | 5 | [*.py] 6 | indent_style = space 7 | indent_size = 4 8 | 9 | [*.rst] 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [Makefile] 14 | indent_style = tab 15 | 16 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | How To Contribute 2 | ================= 3 | 4 | First off, thank you for considering contributing to ``forge``! 5 | 6 | This document intends to make contribution more accessible by codifying tribal knowledge and expectations. 7 | Don't be afraid to open half-finished PRs, and ask questions if something is unclear! 8 | 9 | 10 | Workflow 11 | -------- 12 | 13 | - No contribution is too small! 14 | Please submit as many fixes for typos and grammar bloopers as you can! 15 | - Try to limit each pull request to *one* change only. 16 | - *Always* add tests and docs for your code. 17 | This is a hard rule; patches with missing tests or documentation can't be merged. 18 | - Make sure your changes pass our CI_. 19 | You won't get any feedback until it's green unless you ask for it. 20 | - Once you've addressed review feedback, make sure to bump the pull request with a short note, so we know you're done. 21 | 22 | 23 | Code 24 | ---- 25 | 26 | - Obey :pep:`8` and :pep:`257`. 27 | We use the ``"""``\ -on-separate-lines style for docstrings: 28 | 29 | .. code-block:: python 30 | 31 | def func(x): 32 | """ 33 | Do something. 34 | 35 | :param str x: A very important parameter. 36 | 37 | :rtype: str 38 | """ 39 | - If you add or change public APIs, tag the docstring using ``.. versionadded:: 16.0.0 WHAT`` or ``.. versionchanged:: 16.2.0 WHAT``. 40 | - Prefer double quotes (``"``) over single quotes (``'``) unless the string contains double quotes itself. 41 | 42 | 43 | Tests 44 | ----- 45 | 46 | - Write your asserts as ``actual == expected`` to line them up nicely: 47 | 48 | .. code-block:: python 49 | 50 | x = f() 51 | 52 | assert x.some_attribute == 42 53 | assert x._a_private_attribute == 'foo' 54 | 55 | - To run the test suite, all you need is a recent tox_. 56 | It will ensure the test suite runs with all dependencies against all Python versions just as it will on Travis CI. 57 | - Write `good test docstrings`_. 58 | 59 | 60 | Documentation 61 | ------------- 62 | 63 | - Use `semantic newlines`_ in reStructuredText_ files (files ending in ``.rst``): 64 | 65 | .. code-block:: rst 66 | 67 | This is a sentence. 68 | This is another sentence. 69 | 70 | - If you start a new section, add two blank lines before and one blank line after the header, except if two headers follow immediately after each other: 71 | 72 | .. code-block:: rst 73 | 74 | Last line of previous section. 75 | 76 | 77 | Header of New Top Section 78 | ========================= 79 | 80 | Header of New Section 81 | --------------------- 82 | 83 | First line of new section. 84 | 85 | - If you add a new feature, demonstrate its awesomeness on the `basic page`_! 86 | 87 | 88 | Release 89 | ------- 90 | 91 | The recipe for releasing new software looks like this: 92 | 93 | - Add functionality / docstrings as appropriate 94 | - Add tests / docstrings as necessary 95 | - Update ``documentation`` and ``changelog`` 96 | - Tag release in ``setup.cfg`` (and update badge in the ``README``. 97 | - Merge branch into master 98 | - Add a git tag for the release 99 | - Build a release using ``python setup.py bdist_wheel`` and publish to PYPI as described in `Packaging Python Projects `_ 100 | 101 | 102 | Local Development Environment 103 | ----------------------------- 104 | 105 | You can (and should) run our test suite using tox_. 106 | However, you’ll probably want a more traditional environment as well. 107 | We highly recommend to develop using the latest Python 3 release because ``forge`` tries to take advantage of modern features whenever possible. 108 | 109 | First create a `virtual environment `_. 110 | 111 | Next, get an up to date checkout of the ``forge`` repository: 112 | 113 | .. code-block:: bash 114 | 115 | $ git checkout git@github.com:dfee/forge.git 116 | 117 | Change into the newly created directory and **after activating your virtual environment** install an editable version of ``forge`` along with its tests and docs requirements: 118 | 119 | .. code-block:: bash 120 | 121 | $ cd forge 122 | $ pip install -e .[dev] 123 | 124 | At this point, 125 | 126 | .. code-block:: bash 127 | 128 | $ python -m pytest 129 | 130 | should work and pass, as should: 131 | 132 | .. code-block:: bash 133 | 134 | $ cd docs 135 | $ make html 136 | 137 | The built documentation can then be found in ``docs/_build/html/``. 138 | 139 | 140 | Governance 141 | ---------- 142 | 143 | ``forge`` is maintained by `Devin Fee`_, who welcomes any and all help. 144 | If you'd like to help, just get a pull request merged and ask to be added in the very same pull request! 145 | 146 | **** 147 | 148 | Thank you for contributing to ``forge``! 149 | 150 | 151 | .. _`Devin Fee`: https://devinfee.com 152 | .. _`good test docstrings`: https://jml.io/pages/test-docstrings.html 153 | .. _changelog: https://github.com/dfee/forge/blob/master/CHANGELOG.rst 154 | .. _tox: https://tox.readthedocs.io/ 155 | .. _reStructuredText: http://www.sphinx-doc.org/en/stable/rest.html 156 | .. _semantic newlines: http://rhodesmill.org/brandon/2012/one-sentence-per-line/ 157 | .. _basic page: https://github.com/dfee/forge/blob/master/docs/basic.rst 158 | .. _CI: https://travis-ci.org/forge/dfee/ 159 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Pull Request Check List 2 | 3 | Please make sure that you tick all *appropriate* boxes. 4 | 5 | - [ ] Added **tests** for changed code. 6 | - [ ] Updated **documentation** for changed code. 7 | - [ ] Documentation in `.rst` files is written using [semantic newlines](http://rhodesmill.org/brandon/2012/one-sentence-per-line/). 8 | - [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded). 9 | 10 | If you have *any* questions to *any* of the points above, just **submit and ask**! This checklist is here to *help* you, not to deter you from contributing! -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Runtime 2 | env*/ 3 | 4 | # Build 5 | *.egg 6 | *.egg-info 7 | build/ 8 | dist/ 9 | docs/_build 10 | 11 | # Testing 12 | .coverage* 13 | .pytest_cache/ 14 | .mypy_cache/ 15 | .tox/ 16 | coverage 17 | coverage.xml 18 | test 19 | 20 | # Cache 21 | .DS_Store 22 | *.pyc 23 | *$py.class 24 | 25 | # Editor 26 | .vscode/ 27 | *.sublime-project 28 | *.sublime-workspace 29 | .*.sw? 30 | .sw? 31 | 32 | # etc. 33 | TODO 34 | play/ -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | multi_line_output = 3 3 | include_trailing_comma = true -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=CVS,snapshots 13 | 14 | # Add files or directories matching the regex patterns to the blacklist. The 15 | # regex matches against base names, not paths. 16 | ignore-patterns= 17 | 18 | # Pickle collected data for later comparisons. 19 | persistent=yes 20 | 21 | # List of plugins (as comma separated values of python modules names) to load, 22 | # usually to register additional checkers. 23 | load-plugins= 24 | 25 | # Use multiple processes to speed up Pylint. 26 | jobs=1 27 | 28 | # Allow loading of arbitrary C extensions. Extensions are imported into the 29 | # active Python interpreter and may run arbitrary code. 30 | unsafe-load-any-extension=no 31 | 32 | # A comma-separated list of package or module names from where C extensions may 33 | # be loaded. Extensions are loading into the active Python interpreter and may 34 | # run arbitrary code 35 | extension-pkg-whitelist= 36 | 37 | # Allow optimization of some AST trees. This will activate a peephole AST 38 | # optimizer, which will apply various small optimizations. For instance, it can 39 | # be used to obtain the result of joining multiple strings with the addition 40 | # operator. Joining a lot of strings can lead to a maximum recursion error in 41 | # Pylint and this flag can prevent that. It has one side effect, the resulting 42 | # AST will be different than the one from reality. This option is deprecated 43 | # and it will be removed in Pylint 2.0. 44 | optimize-ast=no 45 | 46 | 47 | [MESSAGES CONTROL] 48 | 49 | # Only show warnings with the listed confidence levels. Leave empty to show 50 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 51 | confidence= 52 | 53 | # Enable the message, report, category or checker with the given id(s). You can 54 | # either give multiple identifier separated by comma (,) or put this option 55 | # multiple time (only on the command line, not in the configuration file where 56 | # it should appear only once). See also the "--disable" option for examples. 57 | #enable= 58 | 59 | # Disable the message, report, category or checker with the given id(s). You 60 | # can either give multiple identifiers separated by comma (,) or put this 61 | # option multiple times (only on the command line, not in the configuration 62 | # file where it should appear only once).You can also use "--disable=all" to 63 | # disable everything first and then reenable specific checks. For example, if 64 | # you want to run only the similarities checker, you can use "--disable=all 65 | # --enable=similarities". If you want to run only the classes checker, but have 66 | # no Warning level messages displayed, use"--disable=all --enable=classes 67 | # --disable=W" 68 | disable= 69 | C0111, missing-docstring 70 | C0302, too-many-lines, 71 | C0304, missing-final-newline, 72 | E1127, invalid-slice-index, 73 | R0901, too-many-ancestors, 74 | R0903, too-few-public-methods, 75 | R0904, too-many-public-methods, 76 | W0212, protected-access, 77 | 78 | 79 | [REPORTS] 80 | 81 | # Set the output format. Available formats are text, parseable, colorized, msvs 82 | # (visual studio) and html. You can also give a reporter class, eg 83 | # mypackage.mymodule.MyReporterClass. 84 | output-format=text 85 | 86 | # Put messages in a separate file for each module / package specified on the 87 | # command line instead of printing them on stdout. Reports (if any) will be 88 | # written in a file name "pylint_global.[txt|html]". This option is deprecated 89 | # and it will be removed in Pylint 2.0. 90 | files-output=no 91 | 92 | # Tells whether to display a full report or only the messages 93 | reports=yes 94 | 95 | # Python expression which should return a note less than 10 (10 is the highest 96 | # note). You have access to the variables errors warning, statement which 97 | # respectively contain the number of errors / warnings messages and the total 98 | # number of statements analyzed. This is used by the global evaluation report 99 | # (RP0004). 100 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 101 | 102 | # Template used to display messages. This is a python new-style format string 103 | # used to format the message information. See doc for all details 104 | #msg-template= 105 | 106 | 107 | [BASIC] 108 | 109 | # Good variable names which should always be accepted, separated by a comma 110 | good-names=_,f,i,j,k,v,id,pk,db,tm,tx 111 | 112 | # Bad variable names which should always be refused, separated by a comma 113 | bad-names=foo,bar,baz,toto,tutu,tata 114 | 115 | # Colon-delimited sets of names that determine each other's naming style when 116 | # the name regexes allow several styles. 117 | name-group= 118 | 119 | # Include a hint for the correct naming format with invalid-name 120 | include-naming-hint=no 121 | 122 | # List of decorators that produce properties, such as abc.abstractproperty. Add 123 | # to this list to register other decorators that produce valid properties. 124 | property-classes=abc.abstractproperty 125 | 126 | # Regular expression matching correct variable names 127 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 128 | 129 | # Naming hint for variable names 130 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 131 | 132 | # Regular expression matching correct method names 133 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 134 | 135 | # Naming hint for method names 136 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 137 | 138 | # Regular expression matching correct inline iteration names 139 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 140 | 141 | # Naming hint for inline iteration names 142 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 143 | 144 | # Regular expression matching correct class attribute names 145 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 146 | 147 | # Naming hint for class attribute names 148 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 149 | 150 | # Regular expression matching correct function names 151 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 152 | 153 | # Naming hint for function names 154 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 155 | 156 | # Regular expression matching correct constant names 157 | const-rgx=(([A-Za-z_][A-Za-z0-9_]*)|(__.*__))$ 158 | 159 | # Naming hint for constant names 160 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 161 | 162 | # Regular expression matching correct attribute names 163 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 164 | 165 | # Naming hint for attribute names 166 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 167 | 168 | # Regular expression matching correct module names 169 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 170 | 171 | # Naming hint for module names 172 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 173 | 174 | # Regular expression matching correct argument names 175 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 176 | 177 | # Naming hint for argument names 178 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 179 | 180 | # Regular expression matching correct class names 181 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 182 | 183 | # Naming hint for class names 184 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 185 | 186 | # Regular expression which should only match function or class names that do 187 | # not require a docstring. 188 | no-docstring-rgx=^_ 189 | 190 | # Minimum line length for functions/classes that require docstrings, shorter 191 | # ones are exempt. 192 | docstring-min-length=-1 193 | 194 | 195 | [ELIF] 196 | 197 | # Maximum number of nested blocks for function / method body 198 | max-nested-blocks=5 199 | 200 | 201 | [FORMAT] 202 | 203 | # Maximum number of characters on a single line. 204 | max-line-length=80 205 | 206 | # Regexp for a line that is allowed to be longer than the limit. 207 | ignore-long-lines=^\s*(# )??$ 208 | 209 | # Allow the body of an if to be on the same line as the test if there is no 210 | # else. 211 | single-line-if-stmt=no 212 | 213 | # List of optional constructs for which whitespace checking is disabled. `dict- 214 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 215 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 216 | # `empty-line` allows space-only lines. 217 | no-space-check=trailing-comma,dict-separator 218 | 219 | # Maximum number of lines in a module 220 | max-module-lines=1000 221 | 222 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 223 | # tab). 224 | indent-string=' ' 225 | 226 | # Number of spaces of indent required inside a hanging or continued line. 227 | indent-after-paren=4 228 | 229 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 230 | expected-line-ending-format= 231 | 232 | 233 | [LOGGING] 234 | 235 | # Logging modules to check that the string format arguments are in logging 236 | # function parameter format 237 | logging-modules=logging 238 | 239 | 240 | [MISCELLANEOUS] 241 | 242 | # List of note tags to take in consideration, separated by a comma. 243 | notes=FIXME,XXX,TODO 244 | 245 | 246 | [SIMILARITIES] 247 | 248 | # Minimum lines number of a similarity. 249 | # DERAULT: min-similarity-lines=4 250 | min-similarity-lines=15 251 | 252 | # Ignore comments when computing similarities. 253 | # DEFAULT: ignore-comments=yes 254 | 255 | # Ignore docstrings when computing similarities. 256 | # DEFAULT: ignore-docstrings=yes 257 | 258 | # Ignore imports when computing similarities. 259 | # DEFAULT: ignore-imports=no 260 | ignore-imports=yes 261 | 262 | 263 | [SPELLING] 264 | 265 | # Spelling dictionary name. Available dictionaries: none. To make it working 266 | # install python-enchant package. 267 | spelling-dict= 268 | 269 | # List of comma separated words that should not be checked. 270 | spelling-ignore-words= 271 | 272 | # A path to a file that contains private dictionary; one word per line. 273 | spelling-private-dict-file= 274 | 275 | # Tells whether to store unknown words to indicated private dictionary in 276 | # --spelling-private-dict-file option instead of raising a message. 277 | spelling-store-unknown-words=no 278 | 279 | 280 | [TYPECHECK] 281 | 282 | # Tells whether missing members accessed in mixin class should be ignored. A 283 | # mixin class is detected if its name ends with "mixin" (case insensitive). 284 | ignore-mixin-members=yes 285 | 286 | # List of module names for which member attributes should not be checked 287 | # (useful for modules/projects where namespaces are manipulated during runtime 288 | # and thus existing member attributes cannot be deduced by static analysis. It 289 | # supports qualified module names, as well as Unix pattern matching. 290 | ignored-modules= 291 | 292 | # List of class names for which member attributes should not be checked (useful 293 | # for classes with dynamically set attributes). This supports the use of 294 | # qualified names. 295 | ignored-classes=optparse.Values,thread._local,_thread._local 296 | 297 | # List of members which are set dynamically and missed by pylint inference 298 | # system, and so shouldn't trigger E1101 when accessed. Python regular 299 | # expressions are accepted. 300 | generated-members= 301 | 302 | # List of decorators that produce context managers, such as 303 | # contextlib.contextmanager. Add to this list to register other decorators that 304 | # produce valid context managers. 305 | contextmanager-decorators=contextlib.contextmanager 306 | 307 | 308 | [VARIABLES] 309 | 310 | # Tells whether we should check for unused import in __init__ files. 311 | init-import=no 312 | 313 | # A regular expression matching the name of dummy variables (i.e. expectedly 314 | # not used). 315 | dummy-variables-rgx=(_+[a-zA-Z0-9]*?$)|dummy 316 | 317 | # List of additional names supposed to be defined in builtins. Remember that 318 | # you should avoid to define new builtins when possible. 319 | additional-builtins= 320 | 321 | # List of strings which can identify a callback function by name. A callback 322 | # name must start or end with one of those strings. 323 | callbacks=cb_,_cb 324 | 325 | # List of qualified module names which can have objects that can redefine 326 | # builtins. 327 | redefining-builtins-modules=six.moves,future.builtins 328 | 329 | 330 | [CLASSES] 331 | 332 | # List of method names used to declare (i.e. assign) instance attributes. 333 | defining-attr-methods=__init__,__new__,setUp 334 | 335 | # List of valid names for the first argument in a class method. 336 | valid-classmethod-first-arg=cls 337 | 338 | # List of valid names for the first argument in a metaclass class method. 339 | valid-metaclass-classmethod-first-arg=mcs 340 | 341 | # List of member names, which should be excluded from the protected access 342 | # warning. 343 | exclude-protected=_asdict,_fields,_replace,_source,_make 344 | 345 | 346 | [DESIGN] 347 | 348 | # Maximum number of arguments for function / method 349 | max-args=5 350 | 351 | # Argument names that match this expression will be ignored. Default to name 352 | # with leading underscore 353 | ignored-argument-names=_.* 354 | 355 | # Maximum number of locals for function / method body 356 | max-locals=15 357 | 358 | # Maximum number of return / yield for function / method body 359 | max-returns=6 360 | 361 | # Maximum number of branch for function / method body 362 | max-branches=12 363 | 364 | # Maximum number of statements in function / method body 365 | max-statements=50 366 | 367 | # Maximum number of parents for a class (see R0901). 368 | max-parents=7 369 | 370 | # Maximum number of attributes for a class (see R0902). 371 | max-attributes=7 372 | 373 | # Minimum number of public methods for a class (see R0903). 374 | min-public-methods=2 375 | 376 | # Maximum number of public methods for a class (see R0904). 377 | max-public-methods=20 378 | 379 | # Maximum number of boolean expressions in a if statement 380 | max-bool-expr=5 381 | 382 | 383 | [IMPORTS] 384 | 385 | # Deprecated modules which should not be used, separated by a comma 386 | deprecated-modules=optparse 387 | 388 | # Create a graph of every (i.e. internal and external) dependencies in the 389 | # given file (report RP0402 must not be disabled) 390 | import-graph= 391 | 392 | # Create a graph of external dependencies in the given file (report RP0402 must 393 | # not be disabled) 394 | ext-import-graph= 395 | 396 | # Create a graph of internal dependencies in the given file (report RP0402 must 397 | # not be disabled) 398 | int-import-graph= 399 | 400 | # Force import order to recognize a module as part of the standard 401 | # compatibility libraries. 402 | known-standard-library= 403 | 404 | # Force import order to recognize a module as part of a third party library. 405 | known-third-party=enchant 406 | 407 | # Analyse import fallback blocks. This can be used to support both Python 2 and 408 | # 3 compatible code, which means that the block might have code that exists 409 | # only in one or another interpreter, leading to false positives when analysed. 410 | analyse-fallback-blocks=no 411 | 412 | 413 | [EXCEPTIONS] 414 | 415 | # Exceptions that will emit a warning when being caught. Defaults to 416 | # "Exception" 417 | overgeneral-exceptions=Exception 418 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | build: 2 | image: latest 3 | 4 | python: 5 | version: 3.6 6 | pip_install: true 7 | extra_requirements: [docs] -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | 4 | matrix: 5 | include: 6 | - python: 3.5 7 | env: TOXENV=py35 8 | - python: 3.6 9 | env: TOXENV=py36,coverage,docs,lint 10 | - python: nightly 11 | env: TOXENV=py37 12 | # https://github.com/travis-ci/travis-ci/issues/9542 13 | # - python: pypy-6.0.0 14 | # env: TOXENV=pypy 15 | 16 | install: 17 | - travis_retry pip install tox 18 | 19 | script: 20 | - travis_retry tox 21 | 22 | after_success: 23 | - pip install coveralls 24 | - coveralls -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | ``forge`` is written and maintained by `Devin Fee`_. 5 | 6 | A full list of contributors can be found in `GitHub's overview`_ 7 | 8 | Special thanks goes to `attrs`_ whose API was heavily studied and consulted, and whose documentation provided a skeleton. 9 | 10 | .. _`Devin Fee`: https://devinfee.com 11 | .. _`attrs`: https://attrs.org 12 | .. _`GitHub's overview`: https://github.com/dfee/forgery/graphs/contributors 13 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Version history 3 | =============== 4 | 5 | Versions follow `CalVer `_ with a strict backwards compatibility policy. 6 | The first digit is the year, the second digit is the month, the third digit is for regressions. 7 | 8 | .. _changelog_2018-6-0: 9 | 10 | 2018.6.0 11 | ======== 12 | 13 | *Released on 2018-06-13* 14 | 15 | - This update is a complete re-design of the ``forge`` library. 16 | - ``forge.sign`` is now a subclass of ``forge.Revision``, and ``forge.resign`` is integrated into ``forge.Revision.__call__``. 17 | - the following group revisions have been introduced: 18 | - ``forge.compose``, 19 | - ``forge.copy``, 20 | - ``forge.manage``, 21 | - ``forge.returns`` 22 | - ``forge.synthesize`` (a.k.a. ``forge.sign``) 23 | - ``forge.sort`` 24 | - the following unit revisions have been introduced: 25 | - ``forge.delete``, 26 | - ``forge.insert``, 27 | - ``forge.modiy``, 28 | - ``forge.replace`` 29 | - ``forge.translocate`` (a.k.a. ``forge.move``) 30 | - Marker classes are no longer singletons (instances can be produced) 31 | - ``stringify_callable`` is now the more serious ``repr_callable`` 32 | - introducing ``callwith``, a function that receives a callable and arguments, orders the arguments and returns with a call to the supplied callable 33 | - ``var-positional`` and ``var-keyword`` parameters now accept a ``type`` argument 34 | 35 | 36 | .. _changelog_2018-5-0: 37 | 38 | 2018.5.0 39 | ======== 40 | 41 | *Released on 2018-05-15* 42 | 43 | - Initial release 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Devin Fee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | 3 | url = "https://pypi.python.org/simple" 4 | verify_ssl = true 5 | name = "pypi" 6 | 7 | 8 | [packages] 9 | 10 | "e1839a8" = {path = ".", extras = ["testing"], editable = true} 11 | sphinx-autodoc-typehints = "*" 12 | sphinx-paramlinks = "*" 13 | twine = "*" 14 | 15 | 16 | [dev-packages] 17 | 18 | "e1839a8" = {path = ".", extras = ["testing"], editable = true} 19 | ipdb = "*" 20 | ipython = "*" 21 | tox = "*" 22 | sphinx = "*" 23 | sphinx-autobuild = "*" 24 | sphinx-rtd-theme = "*" 25 | 26 | 27 | [requires] 28 | 29 | python_version = "3.6" 30 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://raw.githubusercontent.com/dfee/forge/master/docs/_static/forge-horizontal.png 2 | :alt: forge logo 3 | 4 | ================================================== 5 | ``forge`` *(python) signatures for fun and profit* 6 | ================================================== 7 | 8 | 9 | .. image:: https://img.shields.io/badge/pypi-v2018.6.0-blue.svg 10 | :target: https://pypi.org/project/python-forge/ 11 | :alt: pypi project 12 | .. image:: https://img.shields.io/badge/license-MIT-blue.svg 13 | :target: https://pypi.org/project/python-forge/ 14 | :alt: MIT license 15 | .. image:: https://img.shields.io/badge/python-3.5%2C%203.6%2C%203.7-blue.svg 16 | :target: https://pypi.org/project/python-forge/ 17 | :alt: Python 3.5+ 18 | .. image:: https://travis-ci.org/dfee/forge.png?branch=master 19 | :target: https://travis-ci.org/dfee/forge 20 | :alt: master Travis CI Status 21 | .. image:: https://coveralls.io/repos/github/dfee/forge/badge.svg?branch=master 22 | :target: https://coveralls.io/github/dfee/forge?branch=master 23 | :alt: master Coveralls Status 24 | .. image:: https://readthedocs.org/projects/python-forge/badge/ 25 | :target: http://python-forge.readthedocs.io/en/latest/ 26 | :alt: Documentation Status 27 | 28 | .. overview-start 29 | 30 | ``forge`` is an elegant Python package for revising function signatures at runtime. 31 | This libraries aim is to help you write better, more literate code with less boilerplate. 32 | 33 | .. overview-end 34 | 35 | 36 | .. installation-start 37 | 38 | Installation 39 | ============ 40 | 41 | ``forge`` is a Python-only package `hosted on PyPI `_ for **Python 3.5+**. 42 | 43 | The recommended installation method is `pip-installing `_ into a `virtualenv `_: 44 | 45 | .. code-block:: console 46 | 47 | $ pip install python-forge 48 | 49 | .. installation-end 50 | 51 | 52 | 53 | Example 54 | ======= 55 | 56 | .. example-start 57 | 58 | Consider a library like `requests `_ that provides a useful API for performing HTTP requests. 59 | Every HTTP method has it's own function which is a thin wrapper around ``requests.Session.request``. 60 | The code is a little more than 150 lines, with about 90% of that being boilerplate. 61 | Using ``forge`` we can get that back down to about 10% it's current size, while *increasing* the literacy of the code. 62 | 63 | .. code-block:: python 64 | 65 | import forge 66 | import requests 67 | 68 | request = forge.copy(requests.Session.request, exclude='self')(requests.request) 69 | 70 | def with_method(method): 71 | revised = forge.modify( 72 | 'method', default=method, bound=True, 73 | kind=forge.FParameter.POSITIONAL_ONLY, 74 | )(request) 75 | revised.__name__ = method.lower() 76 | return revised 77 | 78 | post = with_method('POST') 79 | get = with_method('GET') 80 | put = with_method('PUT') 81 | delete = with_method('DELETE') 82 | options = with_method('OPTIONS') 83 | head = with_method('HEAD') 84 | patch = with_method('PATCH') 85 | 86 | So what happened? 87 | The first thing we did was create an alternate ``request`` function to replace ``requests.request`` that provides the exact same functionality but makes its parameters explicit: 88 | 89 | .. code-block:: python 90 | 91 | # requests.get() looks like this: 92 | assert forge.repr_callable(requests.get) == 'get(url, params=None, **kwargs)' 93 | 94 | # our get() calls the same code, but looks like this: 95 | assert forge.repr_callable(get) == ( 96 | 'get(url, params=None, data=None, headers=None, cookies=None, ' 97 | 'files=None, auth=None, timeout=None, allow_redirects=True, ' 98 | 'proxies=None, hooks=None, stream=None, verify=None, cert=None, ' 99 | 'json=None' 100 | ')' 101 | ) 102 | 103 | Next, we built a factory function ``with_method`` that creates new functions which make HTTP requests with the proper HTTP verb. 104 | Because the ``method`` parameter is bound, it won't show up it is removed from the resulting functions signature. 105 | Of course, the signature of these generated functions remains explicit, let's try it out: 106 | 107 | .. code-block:: python 108 | 109 | response = get('http://google.com') 110 | assert 'Feeling Lucky' in response.text 111 | 112 | You can review the alternate code (the actual implementation) by visiting the code for `requests.api `_. 113 | 114 | .. example-end 115 | 116 | 117 | .. project-information-start 118 | 119 | Project information 120 | =================== 121 | 122 | ``forge`` is released under the `MIT `_ license, 123 | its documentation lives at `Read the Docs `_, 124 | the code on `GitHub `_, 125 | and the latest release on `PyPI `_. 126 | It’s rigorously tested on Python 3.6+ and PyPy 3.5+. 127 | 128 | ``forge`` is authored by `Devin Fee `_. 129 | Other contributors are listed under https://github.com/dfee/forge/graphs/contributors. 130 | 131 | .. project-information-end 132 | 133 | 134 | .. _requests_api_get: https://github.com/requests/requests/blob/991e8b76b7a9d21f698b24fa0058d3d5968721bc/requests/api.py#L61 135 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = forge 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | AUTOBUILDOPTS = -z ../forge -B 11 | 12 | # Put it first so that "make" without argument is like "make help". 13 | help: 14 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 15 | 16 | .PHONY: help Makefile 17 | 18 | # Catch-all target: route all unknown targets to Sphinx using the new 19 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 20 | %: Makefile 21 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 22 | 23 | livehtml: 24 | sphinx-autobuild -b html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(AUTOBUILDOPTS) -------------------------------------------------------------------------------- /docs/_static/forge-horizontal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfee/forge/c9d13f186a9bd240c47e76cda1522c440b799660/docs/_static/forge-horizontal.png -------------------------------------------------------------------------------- /docs/_static/forge-vertical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfee/forge/c9d13f186a9bd240c47e76cda1522c440b799660/docs/_static/forge-vertical.png -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | API Reference 3 | ============= 4 | 5 | .. currentmodule:: forge 6 | 7 | .. _api_config: 8 | 9 | Config 10 | ====== 11 | 12 | .. autofunction:: forge.get_run_validators 13 | 14 | .. autofunction:: forge.set_run_validators 15 | 16 | 17 | .. _api_exceptions: 18 | 19 | Exceptions 20 | ========== 21 | 22 | .. autoclass:: forge.ForgeError 23 | 24 | .. autoclass:: forge.ImmutableInstanceError 25 | 26 | 27 | .. _api_marker: 28 | 29 | Marker 30 | ====== 31 | 32 | .. autoclass:: forge.empty 33 | :members: 34 | 35 | .. autoclass:: forge.void 36 | :members: 37 | 38 | 39 | .. _api_revisions: 40 | 41 | Revisions 42 | ========= 43 | 44 | .. autoclass:: forge.Mapper 45 | :members: 46 | 47 | .. autoclass:: forge.Revision 48 | :members: 49 | :special-members: __call__ 50 | 51 | 52 | .. _api_revisions_group: 53 | 54 | Group revisions 55 | --------------- 56 | 57 | .. autoclass:: forge.compose 58 | :members: 59 | 60 | .. autoclass:: forge.copy 61 | :members: 62 | 63 | .. autoclass:: forge.manage 64 | :members: 65 | 66 | .. autoclass:: forge.returns 67 | :members: 68 | :special-members: __call__ 69 | 70 | .. autoclass:: forge.synthesize 71 | :members: 72 | 73 | .. data:: forge.sign 74 | 75 | a convenience "short-name" for :class:`~forge.synthesize` 76 | 77 | .. autoclass:: forge.sort 78 | :members: 79 | 80 | 81 | .. _api_revisions_unit: 82 | 83 | Unit revisions 84 | -------------- 85 | .. autoclass:: forge.delete 86 | :members: 87 | 88 | .. autoclass:: forge.insert 89 | :members: 90 | 91 | .. autoclass:: forge.modify 92 | :members: 93 | 94 | .. autoclass:: forge.replace 95 | :members: 96 | 97 | .. autoclass:: forge.translocate 98 | :members: 99 | 100 | .. data:: forge.move 101 | 102 | a convenience "short-name" for :class:`~forge.translocate` 103 | 104 | 105 | .. _api_signature: 106 | 107 | Signature 108 | ========= 109 | 110 | .. _api_signature-classes: 111 | 112 | Classes 113 | ------- 114 | .. autoclass:: forge.FSignature 115 | :members: 116 | 117 | .. autoclass:: forge.FParameter 118 | :members: 119 | 120 | .. autoclass:: forge.Factory 121 | :members: 122 | 123 | 124 | .. _api_signature-constructors: 125 | 126 | Constructors 127 | ------------ 128 | .. autofunction:: forge.pos 129 | 130 | .. autofunction:: forge.pok 131 | 132 | .. function:: forge.arg 133 | 134 | a convenience for :func:`forge.pok` 135 | 136 | .. autofunction:: forge.ctx 137 | 138 | .. autofunction:: forge.vpo 139 | 140 | .. autofunction:: forge.kwo 141 | 142 | .. function:: forge.kwarg 143 | 144 | a convenience for :func:`forge.kwo` 145 | 146 | .. autofunction:: forge.vkw 147 | 148 | 149 | .. _api_parameter-helpers: 150 | 151 | Helpers 152 | ------- 153 | 154 | .. autofunction:: findparam 155 | 156 | .. autofunction:: forge.args 157 | 158 | a "ready-to-go" instance of :class:`~forge.VarPositional`, with the name ``args``. 159 | Use as ``*args``, or supply :meth:`~forge.VarPositional.replace` arguments. 160 | For example, to change the name to ``items``: ``*args(name='items')``. 161 | 162 | .. autodata:: forge.kwargs 163 | 164 | a "ready-to-go" instance of :class:`~forge.VarKeyword`, with the name ``kwargs``. 165 | Use as ``**kwargs``, or supply :meth:`~forge.VarKeyword.replace` arguments. 166 | For example, to change the name to ``extras``: ``**kwargs(name='extras')``. 167 | 168 | .. autodata:: forge.self 169 | 170 | a "ready-to-go" instance of :class:`~forge.FParameter` as the ``self`` context parameter. 171 | 172 | .. autodata:: forge.cls 173 | 174 | a "ready-to-go" instance of :class:`~forge.FParameter` as the ``cls`` context parameter. 175 | 176 | 177 | .. _api_utils: 178 | 179 | Utils 180 | ===== 181 | 182 | .. autofunction:: forge.callwith 183 | 184 | .. autofunction:: forge.repr_callable 185 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | 6 | import pkg_resources 7 | 8 | 9 | sys.path.insert(0, os.path.abspath('..')) 10 | 11 | extensions = [ 12 | 'sphinx.ext.autodoc', 13 | 'sphinx.ext.doctest', 14 | 'sphinx_paramlinks', 15 | ] 16 | 17 | templates_path = ['_templates'] 18 | source_suffix = '.rst' 19 | master_doc = 'index' 20 | project = 'forge' 21 | author = 'Devin Fee' 22 | copyright = '2018, ' + author # pylint: disable=W0622, redefined-builtin 23 | 24 | v = pkg_resources.get_distribution('python-forge').parsed_version 25 | version = v.base_version # type: ignore 26 | release = v.public # type: ignore 27 | 28 | language = None 29 | 30 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 31 | pygments_style = 'sphinx' 32 | highlight_language = 'python3' 33 | 34 | html_logo = "_static/forge-vertical.png" 35 | html_theme = "alabaster" 36 | 37 | html_theme_options = { 38 | 'analytics_id': 'UA-119795890-1', 39 | 'font_family': '"Avenir Next", Calibri, "PT Sans", sans-serif', 40 | 'github_repo': 'forge', 41 | 'github_user': 'dfee', 42 | 'github_banner': True, 43 | 'head_font_family': '"Avenir Next", Calibri, "PT Sans", sans-serif', 44 | 'font_size': '16px', 45 | 'page_width': '980px', 46 | 'show_powered_by': False, 47 | 'show_related': False, 48 | } 49 | 50 | html_static_path = ['_static'] 51 | htmlhelp_basename = 'forgedoc' 52 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. _contributing: 2 | 3 | .. include:: ../.github/CONTRIBUTING.rst 4 | -------------------------------------------------------------------------------- /docs/docutils.conf: -------------------------------------------------------------------------------- 1 | [parsers] 2 | [restructuredtext parser] 3 | smart_quotes=yes -------------------------------------------------------------------------------- /docs/glossary.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Glossary 3 | ======== 4 | 5 | General 6 | ======= 7 | 8 | .. glossary:: 9 | 10 | callable 11 | A :term:`callable` is a Python object that receives arguments and returns a result. 12 | Typical examples are **builtin functions** (like :func:`sorted`), **lambda functions**, **traditional functions**, and **class instances** that implement a :meth:`~object.__call__` method. 13 | 14 | parameter 15 | A :term:`parameter` is the atomic unit of a signature. 16 | At minimum it has a ``name`` and a :term:`kind `. 17 | The native Python implementation is :class:`inspect.Parameter` and allows for additional attributes ``default`` and ``type``. 18 | The ``forge`` implementation is available at :class:`forge.FParameter`. 19 | 20 | parameter kind 21 | A parameter's :term:`kind ` determines its position in a signature, and how arguments to the :term:`callable` are mapped. 22 | There are five kinds of parameters: :term:`positional-only`, :term:`positional-or-keyword`, :term:`var-positional`, :term:`keyword-only` and :term:`var-keyword`. 23 | 24 | signature 25 | A :term:`signature` is the interface of a function. 26 | It contains zero or more :term:`parameters `, and optionally a ``return-type`` annotation. 27 | The native Python implementation is :class:`inspect.Signature`. 28 | The ``forge`` implementation is available at :class:`forge.FSignature`. 29 | 30 | variadic parameter 31 | A :term:`variadic parameter` is a :term:`parameter` that accepts one or more :term:`parameters `. 32 | There are two types: the :term:`var-positional` :term:`parameter` (traditionally named ``args``) and the :term:`var-keyword` :term:`parameter` (traditionally named ``kwargs``). 33 | 34 | 35 | Parameter kinds 36 | =============== 37 | 38 | .. glossary:: 39 | 40 | positional-only 41 | A :term:`kind ` of :term:`parameter` that can only receive an unnamed argument. 42 | It is defined canonically as :attr:`inspect.Parameter.POSITIONAL_ONLY`, and is publicly available in ``forge`` as :paramref:`forge.FParameter.POSITIONAL_ONLY`. 43 | 44 | They are followed by :term:`positional-or-keyword`, :term:`var-positional`, :term:`keyword-only` and :term:`var-keyword` :term:`parameters `. 45 | :term:`positional-only` parameters are distinguishable as they are followed by a slash (``/``). 46 | 47 | Consider the builtin function :func:`pow` – with three :term:`positional-only` :term:`parameters `: ``x``, ``y``, and ``z``: 48 | 49 | .. code-block:: python 50 | 51 | >>> help(pow) 52 | Help on built-in function pow in module builtins: 53 | 54 | pow(x, y, z=None, /) 55 | Equivalent to x**y (with two arguments) or x**y % z (with three arguments) 56 | 57 | Some types, such as ints, are able to use a more efficient algorithm when invoked using the three argument form. 58 | >>> pow(2, 2) 59 | 4 60 | >>> pow(x=2, y=2) 61 | TypeError: pow() takes no keyword arguments 62 | 63 | .. note:: 64 | 65 | There is currently no way to compose a function with a :term:`positional-only` parameter in Python without diving deep into the internals of Python, or using a library like ``forge``. 66 | Without diving into the deep internals of Python and without using ``forge``, users are unable to write functions with :term:`positional-only` parameters. 67 | However, as demonstrated above, some builtin functions (such as :func:`pow`) have them. 68 | 69 | Writing functions with :term:`positional-only` parameters is proposed in :pep:`570`. 70 | 71 | positional-or-keyword 72 | A :term:`kind ` of :term:`parameter` that can receive either named or unnamed arguments. 73 | It is defined canonically as :attr:`inspect.Parameter.POSITIONAL_OR_KEYWORD`, and is publicly available in ``forge`` as :paramref:`forge.FParameter.POSITIONAL_OR_KEYWORD`. 74 | 75 | In function signatures, :term:`positional-or-keyword` :term:`parameters ` follow :term:`positional-only` :term:`parameters `. 76 | They are followed by :term:`var-positional`, :term:`keyword-only` and :term:`var-keyword` :term:`parameters `. 77 | :term:`positional-or-keyword` parameters are distinguishable as they are separated from :term:`positional-only` :term:`parameters ` by a slash (``/``). 78 | 79 | Consider the function :func:`isequal` which has two :term:`positional-or-keyword` :term:`parameters `: ``a`` and ``b``: 80 | 81 | .. code-block:: python 82 | 83 | >>> def isequal(a, b): 84 | ... return a == b 85 | >>> isequal(1, 1): 86 | True 87 | >>> isequal(a=1, b=2): 88 | False 89 | 90 | var-positional 91 | A :term:`kind ` of :term:`parameter` that receives unnamed arguments that are not associated with a :term:`positional-only` or :term:`positional-or-keyword` :term:`parameter`. 92 | It is defined canonically as :attr:`inspect.Parameter.VAR_POSITIONAL`, and is publicly available in ``forge`` as :paramref:`forge.FParameter.VAR_POSITIONAL`. 93 | 94 | In function signatures, the :term:`var-positional` :term:`parameter` follows :term:`positional-only` and :term:`positional-or-keyword` :term:`parameters `. 95 | They are followed by :term:`keyword-only` and :term:`var-keyword` :term:`parameters `. 96 | :term:`var-positional` parameters are distinguishable as their parameter name is prefixed by an asterisk (e.g. ``*args``). 97 | 98 | Consider the stdlib function :func:`os.path.join` which has the :term:`var-positional` :term:`parameter` ``p``: 99 | 100 | .. code-block:: python 101 | 102 | >>> import os 103 | >>> help(os.path.join) 104 | join(a, *p) 105 | Join two or more pathname components, inserting '/' as needed. 106 | If any component is an absolute path, all previous path components will be discarded. 107 | An empty last part will result in a path that ends with a separator. 108 | 109 | >>> os.path.join('/', 'users', 'jack', 'media') 110 | '/users/jack/media' 111 | 112 | keyword-only 113 | A :term:`kind ` of :term:`parameter` that can only receive a named argument. 114 | It is defined canonically as :attr:`inspect.Parameter.KEYWORD_ONLY`, and is publicly available in ``forge`` as :paramref:`forge.FParameter.KEYWORD_ONLY`. 115 | 116 | In function signatures, :term:`keyword-only` :term:`parameters ` follow :term:`positional-only`, :term:`positional-or-keyword` and :term:`var-positional` :term:`parameters `. 117 | They are followed by the :term:`var-keyword` :term:`parameter`. 118 | :term:`keyword-only` parameters are distinguishable as they follow either an asterisk (``*``) or a :term:`var-positional` :term:`parameter` with an asterisk preceding its name (e.g. ``*args``). 119 | 120 | Consider the function :func:`compare` – with a :term:`keyword-only` :term:`parameter` ``key``: 121 | 122 | .. code-block:: python 123 | 124 | >>> def compare(a, b, *, key=None): 125 | ... if key: 126 | ... return a[key] == b[key] 127 | ... return a == b 128 | >>> compare({'x': 1, 'y':2}, {'x': 1, 'y': 3}) 129 | False 130 | >>> compare({'x': 1, 'y':2}, {'x': 1, 'y': 3}, key='x') 131 | True 132 | >>> compare({'x': 1, 'y':2}, {'x': 1, 'y': 3}, 'x') 133 | TypeError: compare() takes 2 positional arguments but 3 were given 134 | 135 | .. note:: 136 | 137 | Writing functions with :term:`keyword-only` parameters was proposed in :pep:`3102` and accepted in April, 2006. 138 | 139 | 140 | var-keyword 141 | A :term:`kind ` of :term:`parameter` that receives named arguments that are not associated with a :term:`positional-or-keyword` or :term:`keyword-only` :term:`parameter`. 142 | It is defined canonically as :attr:`inspect.Parameter.VAR_KEYWORD`, and is publicly available in ``forge`` as :paramref:`forge.FParameter.VAR_KEYWORD`. 143 | 144 | In function signatures, the :term:`var-keyword` :term:`parameter` follows :term:`positional-only`, :term:`positional-or-keyword`, :term:`var-positional`, and :term:`keyword-only` :term:`parameters `. 145 | It is distinguished with two asterisks that precedes the name. 146 | :term:`var-keyword` parameters are distinguishable as their parameter name is prefixed by two asterisks (e.g. ``**kwargs``). 147 | 148 | Consider the :class:`types.SimpleNamespace` constructor which takes only the :term:`var-keyword` parameter ``kwargs``: 149 | 150 | .. code-block:: python 151 | 152 | >>> from types import SimpleNamespace 153 | >>> help(SimpleNamespace) 154 | class SimpleNamespace(builtins.object) 155 | | A simple attribute-based namespace. 156 | | 157 | | SimpleNamespace(**kwargs) 158 | | ... 159 | >>> SimpleNamespace(a=1, b=2, c=3) 160 | namespace(a=1, b=2, c=3) 161 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ================================================== 2 | ``forge`` *(python) signatures for fun and profit* 3 | ================================================== 4 | 5 | Release v\ |release| (:doc:`What's new? `). 6 | 7 | .. include:: ../README.rst 8 | :start-after: overview-start 9 | :end-before: overview-end 10 | 11 | 12 | .. include:: ../README.rst 13 | :start-after: installation-start 14 | :end-before: installation-end 15 | 16 | 17 | Overview 18 | ======== 19 | 20 | - :doc:`philosophy` walks you through the design considerations and need for ``forge``. 21 | Read this to understand the value proposition through a case-study review of the standard library's :mod:`logging` module. 22 | - :doc:`signature` gives a comprehensive overview of the ``forge`` data structures. 23 | Read this to learn how to take full advantage of the power of ``forge``. 24 | - :doc:`revision` describes the signature revision utilities included with ``forge``. 25 | This is a narrative on how to use "the features". 26 | - :doc:`patterns` depicts some common use-cases and approaches for using ``forge``. 27 | - :doc:`api` has documentation for all public functionality. 28 | - :doc:`glossary` irons-out the terminology necessary to harness the power of ``forge``. 29 | 30 | 31 | .. include:: ../README.rst 32 | :start-after: example-start 33 | :end-before: example-end 34 | 35 | 36 | Full Table of Contents 37 | ====================== 38 | 39 | .. toctree:: 40 | :maxdepth: 2 41 | 42 | philosophy 43 | signature 44 | revision 45 | patterns 46 | api 47 | glossary 48 | contributing 49 | license 50 | changelog 51 | 52 | 53 | Indices and tables 54 | ================== 55 | 56 | * :ref:`genindex` 57 | * :ref:`search` 58 | 59 | .. include:: ../README.rst 60 | :start-after: project-information-start 61 | :end-before: project-information-end 62 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | License and Credits 3 | =================== 4 | 5 | 6 | .. include:: ../AUTHORS.rst 7 | 8 | 9 | License 10 | ======= 11 | ``forge`` is licensed under the `MIT license`_. 12 | The full license text can be also found in the `source code repository`_. 13 | 14 | .. _`MIT License`: https://choosealicense.com/licenses/mit/ 15 | .. _`source code repository`: https://github.com/dfee/forge/blob/master/LICENSE 16 | 17 | 18 | Image / Meta 19 | ============ 20 | `Salvador Dali `_, a Spanish surealist artist, is infamous for allegedly forging his own work. In his latter years, it's said that he signed blank canvases and tens of thousands of sheets of lithographic paper (under duress of his guardians). In the image atop this ``README``, he's seen with his pet ocelot, Babou. 21 | 22 | Respectfully speaking, Salvador Dali and his pet ocelot are awesome, and no disgrace is intended by using his picture. 23 | 24 | This image is recomposed from the original, whose metadata is below. 25 | 26 | | **Title**: `Salvatore Dali with ocelot friend at St Regis / World Telegram & Sun photo by Roger Higgins `_ 27 | | **Creator(s)**: Higgins, Roger, photographer 28 | | **Date Created/Published**: 1965. 29 | | **Medium**: 1 photographic print. 30 | | **Reproduction Number**: LC-USZ62-114985 (b&w film copy neg.) 31 | | **Rights Advisory**: No copyright restriction known. Staff photographer reproduction rights transferred to Library of Congress through Instrument of Gift. 32 | | **Repository**: Library of Congress Prints and Photographs Division Washington, D.C. 20540 USA 33 | -------------------------------------------------------------------------------- /docs/patterns.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Patterns and usage 3 | ================== 4 | 5 | Forge enables new software development patterns for Python. 6 | Selected patterns are documented below. 7 | 8 | 9 | Model management 10 | ================ 11 | 12 | This pattern helps you work with objects and their corresponding management methods. 13 | Commonly, models are generated by a ``metaclass`` and convert declartive-style user-code into enhanced Python classes. 14 | 15 | In this example we'll write a service class that manages an ``Article`` object. 16 | For simplicity, we'll use :class:`types.SimpleNamespace` rather than defining our own metaclass and field instances. 17 | 18 | .. testcode:: 19 | 20 | from types import SimpleNamespace 21 | from datetime import datetime 22 | import forge 23 | 24 | class Article(SimpleNamespace): 25 | pass 26 | 27 | def create_article(title=None, text=None): 28 | return Article(title=title, text=text, created_at=datetime.now()) 29 | 30 | @forge.copy(create_article) 31 | def create_draft(**kwargs): 32 | kwargs['title'] = kwargs['title'] or '(draft)' 33 | return create_article(**kwargs) 34 | 35 | assert forge.repr_callable(create_draft) == \ 36 | "create_draft(title=None, text=None)" 37 | 38 | draft = create_draft() 39 | assert draft.title == '(draft)' 40 | 41 | As you can see, ``create_draft`` no longer exposes the :term:`var-keyword` parameter ``kwargs``. 42 | Instead, it has the same function signature as ``create_article``. 43 | 44 | And, as expected, passing a keyword-argument that's not ``title`` or ``text`` raises a TypeError. 45 | 46 | .. testcode:: 47 | 48 | try: 49 | create_draft(author='Abe Lincoln') 50 | except TypeError as exc: 51 | assert exc.args[0] == "create_draft() got an unexpected keyword argument 'author'" 52 | 53 | As expected, ``create_draft`` now raises an error when undefined keyword arguments are passed. 54 | 55 | How about creating another method for *editing* the article? 56 | Let's keep in mind that we might want to erase the ``text`` of the article, so a value of ``None`` is significant. 57 | 58 | In this example we're going to use four revisions: :class:`~forge.compose` (to perform a batch of revisions), :class:`~forge.copy` (to copy another function's signature), :class:`~forge.insert` (to add an additional parameter), and :class:`~forge.modify` (to alter one or more parameters). 59 | 60 | .. testcode:: 61 | 62 | @forge.compose( 63 | forge.copy(create_article), 64 | forge.insert(forge.arg('article'), index=0), 65 | forge.modify( 66 | lambda param: param.name != 'article', 67 | multiple=True, 68 | default=forge.void, 69 | ), 70 | ) 71 | def edit_article(article, **kwargs): 72 | for k, v in kwargs.items(): 73 | if v is not forge.void: 74 | setattr(article, k, v) 75 | 76 | assert forge.repr_callable(edit_article) == \ 77 | "edit_article(article, title=, text=)" 78 | 79 | edit_article(draft, text='hello world') 80 | assert draft.title == '(draft)' 81 | assert draft.text == 'hello world' 82 | 83 | As your ``Article`` class gains more attributes (``author``, ``tags``, ``status``, ``published_on``, etc.) the amount of effort to maintenance, update and test these parameters - or a subset of these parameters – becomes costly and taxing. 84 | 85 | 86 | Var-keyword precision 87 | ===================== 88 | 89 | This pattern is useful when you want to explicitly define which :term:`keyword-only` parameters a callable takes. 90 | This is a useful alternative to provided a generic :term:`var-keyword` and *white-listing* or *black-listing* parameters within the callable's code. 91 | 92 | .. include:: ../README.rst 93 | :start-after: example-start 94 | :end-before: example-end 95 | 96 | 97 | Void arguments 98 | ============== 99 | 100 | The ``void arguments`` pattern allows quick-collection and filtering of arguments. 101 | It is useful when `None` can not or should not be used as a default argument. 102 | This code makes use of :class:`forge.void`. 103 | 104 | Consider the situation where you'd like to make explicit the accepted arguments (i.e. not use the :term:`var-positional` parameter ``**kwargs``), but ``None`` can be used to nullify data (for example with an ORM). 105 | 106 | 107 | .. testcode:: 108 | 109 | import datetime 110 | import forge 111 | 112 | class Book: 113 | __repo__ = {} 114 | 115 | def __init__(self, id, title, author, publication_date): 116 | self.id = id 117 | self.title = title 118 | self.author = author 119 | self.publication_date = publication_date 120 | 121 | @classmethod 122 | def get(cls, book_id): 123 | return cls.__repo__.get(book_id) 124 | 125 | @classmethod 126 | def create(cls, title, author, publication_date): 127 | ins = cls( 128 | id=len(cls.__repo__), 129 | title=title, 130 | author=author, 131 | publication_date=publication_date, 132 | ) 133 | cls.__repo__[ins.id] = ins 134 | return ins.id 135 | 136 | @classmethod 137 | @forge.sign( 138 | forge.cls, 139 | forge.arg('book_id', 'book', converter=lambda ctx, name, value: ctx.get(value)), 140 | forge.kwarg('title', default=forge.void), 141 | forge.kwarg('author', default=forge.void), 142 | forge.kwarg('publication_date', default=forge.void), 143 | ) 144 | def update(cls, book, **kwargs): 145 | for k, v in kwargs.items(): 146 | if v is not forge.void: 147 | setattr(book, k, v) 148 | 149 | assert forge.repr_callable(Book.update) == \ 150 | 'update(book_id, *, title=, author=, publication_date=)' 151 | 152 | book_id = Book.create( 153 | 'Call of the Wild', 154 | 'John London', 155 | datetime.date(1903, 8, 1), 156 | ) 157 | Book.update(book_id, author='Jack London') 158 | assert Book.get(book_id).author == 'Jack London' 159 | 160 | 161 | Chameleon function 162 | ================== 163 | 164 | The ``chameleon function`` pattern demonstrates the powerful functionality of ``forge``. 165 | With this pattern, you gain the ability to dynamically revise a function's signature on demand. 166 | This could be useful for auto-discovered dependency injection. 167 | 168 | .. testcode:: 169 | 170 | import forge 171 | 172 | @forge.compose() 173 | def chameleon(*remove, **kwargs): 174 | globals()['chameleon'] = forge.compose( 175 | forge.copy(chameleon.__wrapped__), 176 | forge.insert([ 177 | forge.kwo(k, default=v) for k, v in kwargs.items() 178 | if k not in remove 179 | ], index=0), 180 | forge.sort(), 181 | )(chameleon) 182 | return kwargs 183 | 184 | # Initial use 185 | assert forge.repr_callable(chameleon) == 'chameleon(*remove, **kwargs)' 186 | 187 | # Empty call preserves signature 188 | assert chameleon() == {} 189 | assert forge.repr_callable(chameleon) == 'chameleon(*remove, **kwargs)' 190 | 191 | # Var-keyword arguments add keyword-only parameters 192 | assert chameleon(a=1) == dict(a=1) 193 | assert forge.repr_callable(chameleon) == 'chameleon(*remove, a=1, **kwargs)' 194 | 195 | # Empty call preserves signature 196 | assert chameleon() == dict(a=1) 197 | 198 | # Var-positional arguments remove keyword-only parameters 199 | assert chameleon('a') == dict(a=1) 200 | assert forge.repr_callable(chameleon) == 'chameleon(*remove, **kwargs)' 201 | -------------------------------------------------------------------------------- /docs/philosophy.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Philosophy 3 | ========== 4 | 5 | Let's dig into why meta-programming function signatures is a good idea, and finish with a defense of the design of ``forge``. 6 | 7 | .. _philosophy-why_how_what: 8 | 9 | Why, what and how 10 | ================= 11 | 12 | .. _philosophy-why_how_what-why: 13 | 14 | **The why**: intuitive design 15 | ----------------------------- 16 | 17 | Python lacks tooling to dynamically create callable signatures (without resorting to :func:`exec`). 18 | While this sounds esoteric, it's actually a big source of error and frustration for authors and users. 19 | Have you ever encountered a function that looks like this? 20 | 21 | .. code-block:: python 22 | 23 | def do_something_complex(*args, **kwargs): 24 | ... 25 | 26 | What arguments does this function recieve? 27 | Is it *really* anything? 28 | Often this is how code-base treasure-hunts begin. 29 | 30 | How about a real world example: the stdlib :mod:`logging` module. 31 | Inspecting one of the six logging methods we get a mostly opaque signature: 32 | 33 | - :func:`logging.debug(msg, *args, **kwargs) `, 34 | - :func:`logging.info(msg, *args, **kwargs) `, 35 | - :func:`logging.warning(msg, *args, **kwargs) `, 36 | - :func:`logging.error(msg, *args, **kwargs) `, 37 | - :func:`logging.critical(msg, *args, **kwargs) `, and 38 | - :func:`logging.exception(msg, *args, **kwargs) `. 39 | 40 | Furthermore, the ``docstring`` messages available via the builtin :func:`help` provide minimally more insight: 41 | 42 | .. code-block:: python 43 | 44 | >>> import logging 45 | >>> help(logging.warning) 46 | Help on function warning in module logging: 47 | 48 | warning(msg, *args, **kwargs) 49 | Log a message with severity 'WARNING' on the root logger. 50 | If the logger has no handlers, call basicConfig() to add a console handler with a pre-defined format. 51 | 52 | 53 | Of course the basic function of :func:`logging.warning` is to output a string, so it'd be excusable if you guessed that ``*args`` and ``**kwargs`` serve the same function as :func:`str.format`. 54 | Let's try that: 55 | 56 | .. code-block:: python 57 | 58 | >>> logging.warning('{user} changed a password', user='dave') 59 | TypeError: _log() got an unexpected keyword argument 'user' 60 | 61 | Oops – perhaps not. 62 | 63 | It's arguable that this signature is *worse* than useless for code consumers - it's led to an incorrect inference of behavior. 64 | If we look at the extended, online documentation for :func:`logging.warning`, we're redirected further to the online documentation for :func:`logging.debug` which clarifies the role of the :term:`var-positional` argument ``*args`` and :term:`var-keyword` argument ``**kwargs`` [#f1]_. 65 | 66 | ``logging.debug(msg, *args, **kwargs)`` 67 | 68 | Logs a message with level DEBUG on the root logger. 69 | The **msg** is the message format string, and the **args** are the arguments which are merged into msg using the string formatting operator. 70 | (Note that this means that you can use keywords in the format string, together with a single dictionary argument.) 71 | 72 | There are three keyword arguments in kwargs which are inspected: **exc_info** which, if it does not evaluate as false, causes exception information to be added to the logging message. 73 | If an exception tuple (in the format returned by sys.exc_info()) is provided, it is used; otherwise, sys.exc_info() is called to get the exception information. 74 | 75 | The second optional keyword argument is **stack_info**, which defaults to False. 76 | If true, stack information is added to the logging message, including the actual logging call. 77 | Note that this is not the same stack information as that displayed through specifying exc_info: The former is stack frames from the bottom of the stack up to the logging call in the current thread, whereas the latter is information about stack frames which have been unwound, following an exception, while searching for exception handlers. 78 | 79 | You can specify stack_info independently of exc_info, e.g. to just show how you got to a certain point in your code, even when no exceptions were raised. 80 | The stack frames are printed following a header line which says: 81 | 82 | ... 83 | 84 | The third optional keyword argument is **extra** which can be used to pass a dictionary which is used to populate the __dict__ of the LogRecord created for the logging event with user-defined attributes. 85 | These custom attributes can then be used as you like. 86 | For example, they could be incorporated into logged messages. For example: 87 | 88 | ... 89 | 90 | That's a bit of documentation, but it uncovers why our attempt at supplying keyword arguments raises a :class:`TypeError`. 91 | The string formatting that the logging methods provide has no relation to the string formatting provided by :meth:`str.format` from :pep:`3101` (introduced in Python 2.6 and Python 3.0). 92 | 93 | In fact, there is a significant amount of documentation clarifying `formatting style compatibility `_ with the :mod:`logging` methods. 94 | 95 | We can also discover what parameters are actually accepted by digging through the source code. 96 | As documentation is (often) lacking, this is a fairly standard process. 97 | 98 | - :func:`logging.warning` calls ``root.warning`` (an instance of :class:`logging.Logger`) [#f2]_ 99 | - :meth:`logging.Logger.warning` calls :meth:`logging.Logger._log`. [#f3]_ 100 | - :meth:`logging.Logger._log` has our expected call signature [#f4]_: 101 | 102 | .. code-block:: python 103 | 104 | def _log(self, level, msg, args, exc_info=None, extra=None, stack_info=False): 105 | """ 106 | Low-level logging routine which creates a LogRecord and then calls 107 | all the handlers of this logger to handle the record. 108 | """ 109 | ... 110 | 111 | So there are our parameters! 112 | 113 | It's understandable that the Python core developers don't want to repeat themselves six times – once for each ``logging`` level. 114 | However, these opaque signatures aren't user-friendly. 115 | 116 | This example illuminates the problem that ``forge`` sets out to solve: writing, testing and maintaining signatures requires too much effort. 117 | Left to their own devices, authors instead resort to hacks like signing a function with a :term:`var-keyword` parameter (e.g. ``**kwargs``). 118 | But is there method madness? Code consumers (collaborators and users) are left in the dark, asking "what parameters are *really* accepted; what should I pass?". 119 | 120 | 121 | .. _philosophy-why_how_what-how: 122 | 123 | **The how**: magic-free manipulation 124 | ------------------------------------ 125 | 126 | Modern Python (3.5+) advertises a ``callable`` signature by looking for: 127 | 128 | #. a :attr:`__signature__` attribute on your callable 129 | #. devising a signature from the :attr:`__code__` attribute of the callable 130 | 131 | And it allows for `type-hints`_ on parameters and return-values by looking for: 132 | 133 | #. an :attr:`__annotations__` attribute on the callable with a ``return`` key 134 | #. devising a signature from the :attr:`__code__` attribute of the callable 135 | 136 | When you call a function signed with ``forge``, the following occurs: 137 | 138 | #. parameters are associated with the supplied **arguments** (as usual) 139 | #. :paramref:`pre-bound ` parameters are added to the mapping of arguments 140 | #. **default** values are provided for missing parameters 141 | #. **converters** (as available) are applied to the default or provided values 142 | #. **validators** (as available) are called with the converted values 143 | #. the arguments are mapped and passed to the underlying :term:`callable` 144 | 145 | 146 | .. _philosophy-why_how_what-what: 147 | 148 | **The what**: applying the knowledge 149 | ------------------------------------ 150 | 151 | Looking back on the code for :func:`logging.debug`, let's try and improve upon this implementation by generating functions with robust signatures to replace the standard logging methods. 152 | 153 | .. testcode:: 154 | 155 | import logging 156 | import forge 157 | 158 | make_explicit = forge.sign( 159 | forge.arg('msg'), 160 | *forge.args, 161 | forge.kwarg('exc_info', default=None), 162 | forge.kwarg('extra', default=None), 163 | forge.kwarg('stack_info', default=False), 164 | ) 165 | debug = make_explicit(logging.debug) 166 | info = make_explicit(logging.info) 167 | warning = make_explicit(logging.warning) 168 | error = make_explicit(logging.error) 169 | critical = make_explicit(logging.critical) 170 | exception = make_explicit(logging.exception) 171 | 172 | assert forge.repr_callable(debug) == \ 173 | 'debug(msg, *args, exc_info=None, extra=None, stack_info=False)' 174 | 175 | Hopefully this is much clearer for the end-user. 176 | 177 | ``Forge`` provides a sane middle-ground for *well-intentioned, albeit lazy* package authors and *pragmatic, albeit lazy* package consumers to communicate functionality and intent. 178 | 179 | 180 | .. _philosophy-why_how_what-bottom_line: 181 | 182 | **The bottom-line**: signatures shouldn't be this hard 183 | ------------------------------------------------------ 184 | After a case-study with enhancing the signatures of the :mod:`logging` module, let's consider the modern state of Python signatures beyond the ``stdlib``. 185 | 186 | Third-party codebases that the broadly adopted (e.g. :mod:`sqlalchemy` and :mod:`graphene`) could benefit, as could third party corporate APIs which expect you to identify subtleties. 187 | 188 | Driving developers from their IDE to your documentation - and then to your codebase - to figure out what parameters are actually accepted is an dark pattern. 189 | Be a good community member – write cleanly and clearly. 190 | 191 | 192 | .. _philosophy-design_defense: 193 | 194 | Design defense 195 | ============== 196 | 197 | .. _philosophy-design_defense-design_principals: 198 | 199 | Principals 200 | ---------- 201 | 202 | **The API emulates usage.** 203 | ``forge`` provides an API for making function signatures more literate - they say what the mean, and they mean what they say. 204 | Therefore, the library, too, is designed in a literate way. 205 | 206 | Users are encouraged to supply :term:`positional-only` and :term:`positional-or-keyword` parameters as positional arguments, the :term:`var-positional` parameter as an expanded sequence (e.g. :func:`*forge.args `), :term:`keyword-only` parameters as keyword arguments, and the :term:`var-keyword` parameter as an expanded dictionary (e.g. :func:`**forge.kwargs `). 207 | 208 | **Minimal API impact.** 209 | Your callable, and it's underlying code is unmodified (except when using :class:`forge.returns` without another signature revision). 210 | You can even get the original function by accessing the function's :attr:`__wrapped__` attribute. 211 | 212 | Callable in, function out: no hybrid instance-callables produced. 213 | :func:`classmethod`, :func:`staticmethod`, and :func:`property` are all supported, as well as ``coroutine`` functions. 214 | 215 | **Performance matters.** 216 | ``forge`` was written from the ground up with an eye on performance, so it does the heavy lifting once, upfront, rather than every time it's called. 217 | 218 | :class:`~forge.FSignature`, :class:`~forge.FParameter` and :class:`~forge.Mapper` use :attr:`__slots__` for faster attribute access. 219 | 220 | PyPy 6.0.0+ has first class support. 221 | 222 | **Immutable and flexible.** 223 | The core ``forge`` classes are immutable, but also flexible enough to support dynamic usage. 224 | You can share an :class:`FParameter` or :class:`FSignature` without fearing strange side-effects might occur. 225 | 226 | **Type-hints available.** 227 | ``forge`` supports the use of `type-hints`_ by providing an API for supplying types on parameters. 228 | In addition, ``forge`` itself is written with `type-hints`_. 229 | 230 | **100% covered and linted.** 231 | ``forge`` maintains 100% code-coverage through unit testing. 232 | Code is also linted with ``mypy`` and ``pylint`` during automated testing upon every ``git push``. 233 | 234 | 235 | .. _philosophy-design_defense-revision_naming: 236 | 237 | Revision naming 238 | --------------- 239 | 240 | Revisions (the unit of work in ``forge``) are subclasses of :class:`~forge.Revision`, and their names are lower case. 241 | This is stylistic choice, as revision instances are callables, typically used as decorators. 242 | 243 | 244 | .. _philosophy-design_defense-parameter_names: 245 | 246 | Parameter names 247 | --------------- 248 | 249 | Many Python developers don't refer to parameters by their formal names. 250 | Given a function that looks like this: 251 | 252 | .. code-block:: python 253 | 254 | def func(a, b=3, *args, c=3, **kwargs): 255 | pass 256 | 257 | - ``a`` is conventionally referred to as an *argument* 258 | - ``c`` is conventionally referred to as a *keyword argument* 259 | - ``b`` is conventionally bucketed as either of the above, 260 | - ``*args`` has implicit meaning and is simply referred to as ``args``, and 261 | - ``**kwargs`` has implicit meaning and is simply referred to as ``kwargs``. 262 | 263 | While conversationally acceptable, its inaccurate. 264 | - ``a`` and ``b`` are :term:`positional-or-keyword` parameters, 265 | - ``c`` is a :term:`keyword-only` parameter, 266 | - ``args`` is a :term:`var-positional` parameter, and 267 | - ``kwargs`` is a :term:`var-keyword` parameter. 268 | 269 | We Python developers are a pragrmatic people, so ``forge`` is written in a supportive manner; the following synonyms are defined: 270 | 271 | - creation of :term:`positional-or-keyword` parameters with :func:`forge.arg` or :func:`forge.pok`, and 272 | - creation of :term:`keyword-only` parameters with :func:`forge.kwarg` or :func:`forge.kwo`. 273 | 274 | Use whichever variant you please. 275 | 276 | In addition, the :class:`forge.args` and :class:`forge.kwargs` expand to produce a sequence of one parameter (the :term:`var-positional` parameter) and a one-item mapping (the value being the :term:`var-keyword` parameter), respectively. 277 | This allows for a function signature, created with :class:`forge.sign` to resemble a native function signature: 278 | 279 | .. testcode:: 280 | 281 | import forge 282 | 283 | @forge.sign( 284 | forge.arg('a'), 285 | *forge.args, 286 | b=forge.kwarg(), 287 | **forge.kwargs, 288 | ) 289 | def func(*args, **kwargs): 290 | pass 291 | 292 | assert forge.repr_callable(func) == 'func(a, *args, b, **kwargs)' 293 | 294 | 295 | .. _philosophy-design_defense-what_forge_is_not: 296 | 297 | What ``forge`` is not 298 | --------------------- 299 | 300 | ``forge`` isn't an interface to the wild-west that is :func:`exec` or :func:`eval`. 301 | 302 | All ``forge`` does is: 303 | 304 | 1. collects a set of revisions 305 | 2. provides an interface wrapper to a supplied callable 306 | 3. routes calls and returns values 307 | 308 | The :class:`~forge.Mapper` is available for inspection (but immutable) at :attr:`__mapper__`. 309 | The supplied callable remains unmodified and intact at :attr:`__wrapped__`. 310 | 311 | 312 | .. _`logging module documentation`: https://docs.python.org/3.6/library/logging.html#logging.debug 313 | .. _`type-hints`: https://docs.python.org/3/library/typing.html 314 | 315 | .. rubric:: Footnotes 316 | 317 | .. [#f1] `logging.debug `_ 318 | .. [#f2] `logging.warning `_ 319 | .. [#f3] `logging.Logger.warning `_ 320 | .. [#f4] `logging.Logger._log `_ 321 | -------------------------------------------------------------------------------- /docs/revision.rst: -------------------------------------------------------------------------------- 1 | ================================================ 2 | Revising signatures (i.e. *forging a signature*) 3 | ================================================ 4 | 5 | The basic *unit of work* with ``forge`` is the ``revision``. 6 | A ``revision`` is an instance of :class:`~forge.Revision` (or a specialized subclass) that provides two interfaces: 7 | 8 | #. a method :meth:`~forge.Revision.revise` that takes a :class:`~forge.FSignature` and returns it unchanged or returns a new :class:`~forge.FSignature`, or 9 | #. the :func:`~forge.Revision.__call__` interface that allows the revision to be used as either a decorator or a function receiving a :term:`callable` to be wrapped. 10 | 11 | :class:`~forge.Revision` subclasses often take initialization arguments that are used during the revision process. 12 | For users, the most practical use of a ``revision`` is as a decorator. 13 | 14 | While not very useful, :class:`~forge.Revision` provides the **identity** revision: 15 | 16 | .. testcode:: 17 | 18 | import forge 19 | 20 | @forge.Revision() 21 | def func(): 22 | pass 23 | 24 | The specialized subclasses are incredibly useful for surgically revising signatures. 25 | 26 | 27 | Group revisions 28 | =============== 29 | 30 | compose 31 | ------- 32 | 33 | The :class:`~forge.compose` revision allows for imperatively describing the changes to a signature from top-to-bottom. 34 | 35 | .. testcode:: 36 | 37 | import forge 38 | 39 | func = lambda a, b, c: None 40 | 41 | @forge.compose( 42 | forge.copy(func), 43 | forge.modify('c', default=None) 44 | ) 45 | def func2(**kwargs): 46 | pass 47 | 48 | assert forge.repr_callable(func2) == 'func2(a, b, c=None)' 49 | 50 | If we were to define recreate this without :class:`~forge.compose`, and instead use multiple signatures, the sequence would look like: 51 | 52 | .. testcode:: 53 | 54 | import forge 55 | 56 | func = lambda a, b, c: None 57 | 58 | @forge.modify('c', default=None) 59 | @forge.copy(func) 60 | def func2(**kwargs): 61 | pass 62 | 63 | assert forge.repr_callable(func2) == 'func2(a, b, c=None)' 64 | 65 | Notice how :class:`~forge.modify` comes before :class:`~forge.copy` in this latter example? 66 | That's because the Python interpreter builds ``func2``, passes it to the the instance of :class:`~forge.copy`, and then passes *that* return value to :class:`~forge.modify`. 67 | 68 | :class:`~forge.compose` is therefore as a useful tool to reason about your code top-to-bottom, rather than in an inverted manner. 69 | However, the resulting call stack and underlying :class:`~forge.Mapper` in the above example are identical. 70 | 71 | Unlike applying multiple decorators, :class:`~forge.compose` does not validate the resulting :class:`~forge.FSignature` during internmediate steps. 72 | This is useful when you want to change either the :term:`kind ` of a parameter or supply a default value - either of which often require a parameter to be moved within the signature. 73 | 74 | .. testcode:: 75 | 76 | import forge 77 | 78 | func = lambda a, b, c: None 79 | 80 | @forge.compose( 81 | forge.copy(func), 82 | forge.modify('a', default=None), 83 | forge.move('a', after='c'), 84 | ) 85 | def func2(**kwargs): 86 | pass 87 | 88 | assert forge.repr_callable(func2) == 'func2(b, c, a=None)' 89 | 90 | After the ``modify`` revision, but before the ``move`` revisions, the signature appears to be ``func2(a=None, b, c)``. 91 | Of course this is an invalid signature, as a :term:`positional-only` or :term:`positional-or-keyword` parameter with a default must follow parameters of the same kind *without* defaults. 92 | 93 | .. note:: 94 | 95 | The :class:`~forge.compose` revision accepts all other revisions (including :class:`~forge.compose`, itself) as arguments. 96 | 97 | 98 | copy 99 | ---- 100 | 101 | The :class:`~forge.copy` revision is straightforward: use it when you want to *copy* the signature from another callable. 102 | 103 | .. testcode:: 104 | 105 | import forge 106 | 107 | func = lambda a, b, c: None 108 | 109 | @forge.copy(func) 110 | def func2(**kwargs): 111 | pass 112 | 113 | assert forge.repr_callable(func2) == 'func2(a, b, c)' 114 | 115 | As you can see, the signature of ``func`` is copied in its entirety to ``func2``. 116 | 117 | .. note:: 118 | 119 | In order to :class:`~forge.copy` a signature, the receiving callable must either have a :term:`var-keyword` parameter which collects the extra keyword arguments (as demonstrated above), or be pre-defined with all the same parameters: 120 | 121 | .. testcode:: 122 | 123 | import forge 124 | 125 | func = lambda a, b, c: None 126 | 127 | @forge.copy(func) 128 | def func2(a=1, b=2, c=3): 129 | pass 130 | 131 | assert forge.repr_callable(func2) == 'func2(a, b, c)' 132 | 133 | The exception is the :term:`var-positional` parameter. 134 | If the new signature takes a :term:`var-positional` parameter (e.g. ``*args``), then the receiving function must also accept a :term:`var-positional` parameter. 135 | 136 | 137 | manage 138 | ------ 139 | 140 | The :class:`~forge.manage` revision lets you supply your own function that receives an instance of :class:`~forge.FSignature`, and returns a new instance. Because :class:`~forge.FSignature` is *immutable*, consider using :func:`~forge.FSignature.replace` to create a new :class:`~forge.FSignature` with updated attribute values or an altered ``return_annotation`` 141 | 142 | .. testcode:: 143 | 144 | import forge 145 | 146 | reverse = lambda prev: prev.replace(parameters=prev[::-1]) 147 | 148 | @forge.manage(reverse) 149 | def func(a, b, c): 150 | pass 151 | 152 | assert forge.repr_callable(func) == 'func(c, b, a)' 153 | 154 | 155 | returns 156 | ------- 157 | 158 | The :class:`~forge.returns` revision alters the return type annotation of the receiving function. 159 | In the case that there are no other revisions, :class:`~forge.returns` updates the receiving signature without wrapping it. 160 | 161 | .. testcode:: 162 | 163 | import forge 164 | 165 | @forge.returns(int) 166 | def func(): 167 | pass 168 | 169 | assert forge.repr_callable(func) == 'func() -> int' 170 | 171 | Of course, if you've defined a return type annotation on a function that has a forged signature, it's return type annotation will stay in place: 172 | 173 | .. testcode:: 174 | 175 | import forge 176 | 177 | @forge.compose() 178 | def func() -> int: 179 | pass 180 | 181 | assert forge.repr_callable(func) == 'func() -> int' 182 | 183 | 184 | sort 185 | ---- 186 | 187 | By default, the :class:`~forge.sort` revision sorts the parameters by :term:`kind `, by whether they have a default value, and then by the name (lexicographically). 188 | 189 | .. testcode:: 190 | 191 | import forge 192 | 193 | @forge.sort() 194 | def func(c, b, a, *, f=None, e, d): 195 | pass 196 | 197 | assert forge.repr_callable(func) == 'func(a, b, c, *, d, e, f=None)' 198 | 199 | :class:`~forge.sort` also accepts a user-defined function (:paramref:`~forge.sort.sortkey`) that receives the signature's :class:`~forge.FParameter` instances and emits a key for sorting. 200 | The underlying implementation relies on :func:`builtins.sorted`, so head on over to the Python docs to jog your memory on how to use ``sortkey``. 201 | 202 | 203 | synthesize *(sign)* 204 | ------------------- 205 | 206 | The :class:`~forge.synthesize` revision (also known as :data:`~forge.sign`) allows you to construct a signature by hand. 207 | 208 | .. testcode:: 209 | 210 | import forge 211 | 212 | @forge.sign( 213 | forge.pos('a'), 214 | forge.arg('b'), 215 | *forge.args, 216 | c=forge.kwarg(), 217 | **forge.kwargs, 218 | ) 219 | def func(*args, **kwargs): 220 | pass 221 | 222 | assert forge.repr_callable(func) == 'func(a, /, b, *args, c, **kwargs)' 223 | 224 | .. warning:: 225 | 226 | When supplying parameters to :class:`~forge.synthesize` or :data:`~forge.sign`, unnamed parameter arguments are ordered by the order they were supplied, whereas named parameter arguments are ordered by their ``createion_order`` 227 | 228 | This design decision is a consequence of Python <= 3.6 not guaranteeing insertion-order for dictionaries (and thus an unorderd :term:`var-keyword` argument). 229 | 230 | It is therefore recommended that when supplying pre-created parameters to :func:`.sign`, that they are specified only as positional arguments: 231 | 232 | .. testcode:: 233 | 234 | import forge 235 | 236 | param_b = forge.arg('b') 237 | param_a = forge.arg('a') 238 | 239 | @forge.sign(a=param_a, b=param_b) 240 | def func1(**kwargs): 241 | pass 242 | 243 | @forge.sign(param_a, param_b) 244 | def func2(**kwargs): 245 | pass 246 | 247 | assert forge.repr_callable(func1) == 'func1(b, a)' 248 | assert forge.repr_callable(func2) == 'func2(a, b)' 249 | 250 | 251 | Unit revisions 252 | ============== 253 | 254 | delete 255 | ------ 256 | 257 | The :class:`~forge.delete` revision removes a parameter from the signature. 258 | This revision requires the receiving function's parameter to have a ``default`` value. 259 | If no ``default`` value is provided, a :exc:`TypeError` will be raised. 260 | 261 | .. testcode:: 262 | 263 | import forge 264 | 265 | @forge.delete('a') 266 | def func(a=1, b=2, c=3): 267 | pass 268 | 269 | assert forge.repr_callable(func) == 'func(b=2, c=3)' 270 | 271 | 272 | insert 273 | ------ 274 | 275 | The :class:`~forge.insert` revision adds a parameter or a sequence of parameters into a signature. 276 | This revision takes the :class:`~forge.FParameter` to insert, and one of the following: :paramref:`~forge.insert.index`, :paramref:`~forge.insert.before`, or :paramref:`~forge.insert.after`. 277 | If ``index`` is supplied, it must be an integer, whereas ``before`` and ``after`` must be the :paramref:`~forge.FParameter.name` of a parameter, an iterable of parameter names, or a function that receives a parameter and returns ``True`` if the parameter matches. 278 | 279 | .. testcode:: 280 | 281 | import forge 282 | 283 | @forge.insert(forge.arg('a'), index=0) 284 | def func(b, c, **kwargs): 285 | pass 286 | 287 | assert forge.repr_callable(func) == 'func(a, b, c, **kwargs)' 288 | 289 | Or, to insert multiple parameters using :paramref:`~forge.FParameter.after` with a parameter name: 290 | 291 | .. testcode:: 292 | 293 | import forge 294 | 295 | @forge.insert([forge.arg('b'), forge.arg('c')], after='a') 296 | def func(a, **kwargs): 297 | pass 298 | 299 | assert forge.repr_callable(func) == 'func(a, b, c, **kwargs)' 300 | 301 | 302 | modify 303 | ------ 304 | 305 | The :class:`~forge.modify` revision modifies one or more of the receiving function's parameters. 306 | It takes a :paramref:`~forge.modify.selector` argument (a parameter name, an iterable of names, or a callable that takes a parameter and returns ``True`` if matched), (optionally) a :paramref:`~forge.modify.multiple` argument (whether to apply the modification to all matching parameters), and keyword-arguments that map to the attributes of the underlying :class:`~forge.FParameter` to modify. 307 | 308 | .. testcode:: 309 | 310 | import forge 311 | 312 | @forge.modify('c', default=None) 313 | def func(a, b, c): 314 | pass 315 | 316 | assert forge.repr_callable(func) == 'func(a, b, c=None)' 317 | 318 | .. warning:: 319 | 320 | When using :class:`~forge.modify` to alter a signature's parameters, keep an eye on the :term:`parameter kind` of surrounding parameters and whether other parameters of the same :term:`parameter kind` lack default values. 321 | 322 | In Python, :term:`positional-only` parameters are followed by :term:`positional-or-keyword` parameters. After that comes the :term:`var-positional` parameter, then any :term:`keyword-only` parameters, and finally an optional :term:`var-keyword` parameter. 323 | 324 | Using :class:`~forge.compose` and :class:`~forge.sort` can be helpful here to ensure that your parameters are properly ordered. 325 | 326 | .. testcode:: 327 | 328 | import forge 329 | 330 | @forge.compose( 331 | forge.modify('b', kind=forge.FParameter.POSITIONAL_ONLY), 332 | forge.sort(), 333 | ) 334 | def func(a, b, c): 335 | pass 336 | 337 | assert forge.repr_callable(func) == 'func(b, /, a, c)' 338 | 339 | 340 | replace 341 | ------- 342 | 343 | The :class:`~forge.replace` revision replaces a parameter outright. 344 | This is a helpful alternative to ``modify`` when it's easier to replace a parameter outright than to alter its state. 345 | :class:`~forge.replace` takes a :paramref:`~forge.replace.selector` argument (a string for matching parameter names, an iterable of strings that contain a parameter's name, or a function that is passed the signature's :class:`~forge.FSignature` parameters and returns ``True`` upon a match) and a new :class:`~forge.FParameter` instance. 346 | 347 | .. testcode:: 348 | 349 | import forge 350 | 351 | @forge.replace('a', forge.pos('a')) 352 | def func(a=0, b=1, c=2): 353 | pass 354 | 355 | assert forge.repr_callable(func) == 'func(a, /, b=1, c=2)' 356 | 357 | 358 | translocate *(move)* 359 | -------------------- 360 | 361 | The :class:`~forge.translocate` revision (also known as :data:`~forge.move`) moves a parameter to another location in the signature. 362 | :paramref:`~forge.translocate.selector`, :paramref:`~forge.translocate.before` and :paramref:`~forge.translocate.after` take a string for matching parameter names, an iterable of strings that contain a parameter's name, or a function that is passed the signature's :class:`~forge.FSignature` parameters and returns ``True`` upon a match. 363 | One (and only one) of :paramref:`~forge.translocate.index`, :paramref:`~forge.translocate.before`, or :paramref:`~forge.translocate.after`, must be provided. 364 | 365 | .. testcode:: 366 | 367 | import forge 368 | 369 | @forge.move('a', after='c') 370 | def func(a, b, c): 371 | pass 372 | 373 | assert forge.repr_callable(func) == 'func(b, c, a)' 374 | 375 | Mapper 376 | ====== 377 | 378 | The :class:`~forge.Mapper` is the glue that connects the :class:`~forge.FSignature` to an underlying :term:`callable`. 379 | You shouldn't need to create a :class:`~forge.Mapper` yourself, but it's helpful to know that you can inspect the :class:`~forge.Mapper` and it's underlying strategy by looking at the ``__mapper__`` attribute on the function returned from a :class:`~forge.Revision`. 380 | -------------------------------------------------------------------------------- /docs/signature.rst: -------------------------------------------------------------------------------- 1 | ======================================= 2 | Signatures, parameters and return types 3 | ======================================= 4 | 5 | Crash course 6 | ============ 7 | 8 | Python :term:`callables ` have a :term:`signature`: the interface which describes what arguments are accepted and (optionally) what kind of value is returned. 9 | 10 | .. testcode:: 11 | 12 | def func(a, b, c): 13 | return a + b + c 14 | 15 | The function ``func`` (above) has the :term:`signature` ``(a, b, c)``. 16 | We know that it requires three arguments, one for ``a``, ``b`` and ``c``. 17 | These (``a``, ``b`` and ``c``) are called :term:`parameters `. 18 | 19 | The :term:`parameter` is the atomic unit of a :term:`signature`. 20 | Every :term:`parameter` has *at a minimum* a ``name`` and a :term:`kind `. 21 | 22 | There are five kinds of parameters, which determine how an argument can be provided to its callable. 23 | These kinds (in order) are: 24 | 25 | #. :term:`positional-only`, 26 | #. :term:`positional-or-keyword`, 27 | #. :term:`var-positional`, 28 | #. :term:`keyword-only` and 29 | #. :term:`var-keyword`. 30 | 31 | As ``forge`` is compatible with Python 3.5+, we can also provide type-hints: 32 | 33 | .. testcode:: 34 | 35 | def func(a: int, b: int, c: int) -> int: 36 | return a + b + c 37 | 38 | Now, ``func`` has the signature ``(a: int, b: int, c: int) -> int``. 39 | Of course, this means that ``func`` takes three integer arguments and returns an integer. 40 | 41 | The Python-native classes for the :term:`signature` and :term:`parameter` are :class:`inspect.Signature` and :class:`inspect.Parameter`. 42 | ``forge`` introduces the companion classes :class:`forge.FSignature` and :class:`forge.FParameter`. 43 | These classes extend the functionality of their Python-native counterparts, and allow for comprehensive signature revision. 44 | 45 | Like :class:`inspect.Signature`, :class:`~forge.FSignature` is a container for a sequence of parameters and (optionally) what kind of value is returned. 46 | The parameters that :class:`~forge.FSignature` contains (instances of :class:`~forge.FParameter`) provide a recipe for building a public :class:`inspect.Parameter` instance that maps to an underlying callable. 47 | 48 | Here's an example, that we'll discuss in detail below: 49 | 50 | .. testcode:: 51 | 52 | import forge 53 | 54 | @forge.modify( 55 | 'private', 56 | name='public', 57 | kind=forge.FParameter.KEYWORD_ONLY, 58 | default=3, 59 | ) 60 | def func(private): 61 | return private 62 | 63 | assert forge.repr_callable(func) == 'func(*, public=3)' 64 | assert func(public=4) == 4 65 | 66 | As you can see, the original definition of ``func`` has one parameter, ``private``. 67 | If you inspect the revised function (e.g. ``help(func)``), however, you'll see a different parameter, ``public``. 68 | The parameter ``public`` has also gained a ``default`` value, and is now a :term:`keyword-only` parameter. 69 | 70 | This system allows for the addition, removal and modification of parameters. 71 | It also allows for argument value **conversion** and **validation** (and more, as described below). 72 | 73 | 74 | FSignature 75 | ========== 76 | 77 | As detailed above, a :class:`~forge.FSignature` is a sequence of :class:`FParameters ` and an optional ``return_annotation`` (type-hint of the return value). 78 | It closely mimics the API of :class:`inspect.Signature`, but it's also implements the ``sequence`` interface, so you can iterate over the underlying parameters. 79 | 80 | 81 | Constructors 82 | ------------ 83 | 84 | The constructor :func:`forge.fsignature` creates a :class:`~forge.FSignature` from a :term:`callable`: 85 | 86 | .. testcode:: 87 | 88 | import forge 89 | import typing 90 | 91 | def func(a:int, b:int, c:int) -> typing.Tuple[int, int, int]: 92 | return (a, b, c) 93 | 94 | fsig = forge.fsignature(func) 95 | 96 | assert fsig.return_annotation == typing.Tuple[int, int, int] 97 | assert [fp.name for fp in fsig] == ['a', 'b', 'c'] 98 | 99 | 100 | Of course, an :class:`~forge.FSignature` can also be created by hand (though it's not usually necessary): 101 | 102 | .. testcode:: 103 | 104 | import forge 105 | 106 | fsig = forge.FSignature( 107 | parameters=[ 108 | forge.arg('a', type=int), 109 | forge.arg('b', type=int), 110 | forge.arg('c', type=int), 111 | ], 112 | return_annotation=typing.Tuple[int, int, int], 113 | ) 114 | 115 | assert fsig.return_annotation == typing.Tuple[int, int, int] 116 | assert [fp.name for fp in fsig] == ['a', 'b', 'c'] 117 | 118 | 119 | :class:`~forge.FSignature` instances also support overloaded ``__getitem__`` access. 120 | You can pass an integer, a slice of integers, a string, or a slice of strings and retrieve certain parameters: 121 | 122 | .. testcode:: 123 | 124 | import forge 125 | 126 | fsig = forge.fsignature(lambda a, b, c: None) 127 | assert fsig[0] == \ 128 | fsig['a'] == \ 129 | forge.arg('a') 130 | assert fsig[0:2] == \ 131 | fsig['a':'b'] == \ 132 | [forge.arg('a'), forge.arg('b')] 133 | 134 | This is useful for certain revisions, like :class:`forge.synthesize` (a.k.a. :class:`forge.sign`) and :class:`forge.insert` which take one or more parameters. 135 | Here is an example of using :class:`forge.sign` to splice in parameters from another function: 136 | 137 | .. testcode:: 138 | 139 | import forge 140 | 141 | func = lambda a=1, b=2, d=4: None 142 | 143 | @forge.sign( 144 | *forge.fsignature(func)['a':'b'], 145 | forge.arg('c', default=3), 146 | forge.fsignature(func)['d'], 147 | ) 148 | def func(**kwargs): 149 | pass 150 | 151 | assert forge.repr_callable(func) == 'func(a=1, b=2, c=3, d=4)' 152 | 153 | 154 | FParameter 155 | ========== 156 | 157 | An :class:`~forge.FParameter` is the atomic unit of an :class:`~forge.FSignature`. 158 | It's primary responsibility is to apply a series of transforms and validations on an value and map that value to the parameter of an underlying callable. 159 | It mimics the API of :class:`inspect.Parameter`, and extends it further to provide enriched functionality for value transformation. 160 | 161 | 162 | Kinds and Constructors 163 | ---------------------- 164 | 165 | The :term:`kind ` of a parameter determines it's position in a signature and how a user can provide its argument value. 166 | There are five :term:`kinds ` of parameters: 167 | 168 | .. list-table:: FParameter Kinds 169 | :header-rows: 1 170 | :widths: 12 8 20 171 | 172 | * - Parameter Kind 173 | - Constant Value 174 | - Constructors 175 | * - :term:`positional-only` 176 | - :paramref:`~forge.FParameter.POSITIONAL_ONLY` 177 | - :func:`forge.pos` 178 | * - :term:`positional-or-keyword` 179 | - :paramref:`~forge.FParameter.POSITIONAL_OR_KEYWORD` 180 | - :func:`forge.pok` (a.k.a. :func:`forge.arg`) 181 | * - :term:`var-positional` 182 | - :paramref:`~forge.FParameter.VAR_POSITIONAL` 183 | - :func:`forge.vpo` (or :data:`*forge.args `) 184 | * - :term:`keyword-only` 185 | - :paramref:`~forge.FParameter.KEYWORD_ONLY` 186 | - :func:`forge.kwo` (a.k.a :func:`forge.kwarg`) 187 | * - :term:`var-keyword` 188 | - :paramref:`~forge.FParameter.VAR_KEYWORD` 189 | - :func:`forge.vko` (or :data:`**forge.kwargs `) 190 | 191 | .. note:: 192 | 193 | The constructor for the :term:`positional-or-keyword` parameter (:func:`forge.pok`) and the constructor for the :term:`keyword-only` parameter (:func:`forge.kwo`) have alternate, *conventional* names: :func:`forge.arg` and :func:`forge.kwarg`, respectively. 194 | 195 | In addition, the constructor for the :term:`var-positional` parameter (:func:`forge.vpo`) and the constructor for the :term:`var-keyword` parameter (:func:`forge.vkw`) have alternate constructors that make their instantiation more semantic: :func:`*forge.args ` and :func:`**forge.kwargs `, respectively. 196 | 197 | As described below, :func:`*forge.args() ` and :func:`**forge.kwargs() ` are also callable, accepting the same parameters as :func:`forge.vpo` and :func:`forge.vkw`, respectively. 198 | 199 | 200 | This subject is quite dense, but the code snippet below – a brief demonstration using the revising :class:`forge.sign` to create a signature with *conventional* names - should help resolve any confusion: 201 | 202 | .. testcode:: 203 | 204 | import forge 205 | 206 | @forge.sign( 207 | forge.pos('my_positional'), 208 | forge.arg('my_positional_or_keyword'), 209 | *forge.args('my_var_positional'), 210 | my_keyword=forge.kwarg(), 211 | **forge.kwargs('my_var_keyword'), 212 | ) 213 | def func(*args, **kwargs): 214 | pass 215 | 216 | assert forge.repr_callable(func) == \ 217 | 'func(my_positional, /, my_positional_or_keyword, *my_var_positional, my_keyword, **my_var_keyword)' 218 | 219 | 220 | Using non-semantic (or *standard*) naming, we can reproduce that same signature: 221 | 222 | .. testcode:: 223 | 224 | import forge 225 | 226 | @forge.sign( 227 | forge.pos('my_positional'), 228 | forge.pok('my_positional_or_keyword'), 229 | forge.vpo('my_var_positional'), 230 | forge.kwo('my_keyword'), 231 | forge.vkw('my_var_keyword'), 232 | ) 233 | def func(*args, **kwargs): 234 | pass 235 | 236 | assert forge.repr_callable(func) == \ 237 | 'func(my_positional, /, my_positional_or_keyword, *my_var_positional, my_keyword, **my_var_keyword)' 238 | 239 | The latter version is less *semantic* in that it looks less like how a function signature would naturally be written. 240 | 241 | .. warning:: 242 | 243 | Positional arguments to :class:`forge.synthesize` (a.k.a. :class:`forge.sign`) are ordered by placement, while keyword-arguments are ordered by initialization order. 244 | In practice, if you're creating :class:`FParameters ` on separate lines and pass them to :class:`forge.sign`, you should opt for *non-conventional* or *standard* naming (as described above). 245 | For more information, read the API documentation for :class:`forge.synthesize`. 246 | 247 | 248 | Naming 249 | ------ 250 | 251 | :class:`FParameters <~forge.FParameter>` have both a (:paramref:`~forge.FParameter.name`) and an (:paramref:`interface name `) - the name of the parameter in the underlying function that is the ultimate recipient of the argument. 252 | This distinction is necessary to support the *re-mapping* of parameters to different names. 253 | One use case might be if you're wrapping auto-generated code and providing sensible :pep:`8` compliant parameter names. 254 | 255 | .. note:: 256 | 257 | ``Variadic`` parameter helpers :data:`forge.args` and :data:`forge.kwargs` (and their constructor counterparts :func:`forge.vpo` and :func:`forge.vkw` don't take an ``interface_name`` parameter, as functions can only have one :term:`var-positional` and one :term:`var-keyword` parameter. 258 | 259 | In addition, ``forge`` does not allow a revised signature to accept either a :term:`var-positional` or :term:`var-keyword` :term:`variadic parameter` unless the underlying callable also has a parameter of the same kind. 260 | 261 | However, the underlying callable may have either a :term:`var-positional` or :term:`var-keyword` :term:`variadic parameter` without the revised signature also having that (respective) :term:`kind ` of parameters. 262 | 263 | :paramref:`~forge.FParameter.name` and :paramref:`~forge.FParameter.interface_name` are the first two parameters for :class:`forge.pos`, :class:`forge.pok` (a.k.a. :class:`forge.arg`), and :class:`forge.kwo` (a.k.a. :class:`forge.kwarg`). 264 | Here is an example of renaming a parameter, by providing :paramref:`~forge.FParameter.interface_name`: 265 | 266 | .. testcode:: 267 | 268 | import forge 269 | 270 | @forge.sign( 271 | forge.arg('value'), 272 | forge.arg('increment_by', 'other_value'), 273 | ) 274 | def func(value, other_value): 275 | return value + other_value 276 | 277 | assert forge.repr_callable(func) == 'func(value, increment_by)' 278 | assert func(3, increment_by=5) == 8 279 | 280 | 281 | Supported by: 282 | 283 | - :term:`positional-only`: via :func:`forge.pos` 284 | - :term:`positional-or-keyword`: via :func:`forge.pok` and :func:`forge.arg` 285 | - :term:`var-positional`: via :data:`forge.vpo` and :func:`forge.args` (``name`` only) 286 | - :term:`keyword-only`: via :func:`forge.kwo` and :func:`forge.kwarg` 287 | - :term:`var-keyword`: via :data:`forge.vkw` and :func:`forge.kwargs` (``name`` only) 288 | 289 | 290 | Defaults 291 | -------- 292 | 293 | :class:`FParameters ` support default values by providing a :paramref:`~forge.FParameter.default` keyword-argument to a non-:term:`variadic parameter`. 294 | 295 | .. testcode:: 296 | 297 | import forge 298 | 299 | @forge.sign(forge.arg('myparam', default=5)) 300 | def func(myparam): 301 | return myparam 302 | 303 | assert forge.repr_callable(func) == 'func(myparam=5)' 304 | assert func() == 5 305 | 306 | Supported by: 307 | 308 | - :term:`positional-only`: via :func:`forge.pos` 309 | - :term:`positional-or-keyword`: via :func:`forge.pok` and :func:`forge.arg` 310 | - :term:`keyword-only`: via :func:`forge.kwo` and :func:`forge.kwarg` 311 | 312 | 313 | Default factory 314 | --------------- 315 | In addition to supporting default values, :class:`FParameters ` also support default factories. 316 | To create default values *on-demand*, provide a :paramref:`~forge.FParamter.factory` keyword-argument. 317 | This argument should be a :term:`callable` that take no arguments and returns a value. 318 | 319 | This is a convenience around passing an instance of :class:`~forge.Factory` to :paramref:`~forge.FParameter.default`. 320 | 321 | .. testcode:: 322 | 323 | from datetime import datetime 324 | import forge 325 | 326 | @forge.sign(forge.arg('when', factory=datetime.now)) 327 | def func(when): 328 | return when 329 | 330 | assert forge.repr_callable(func) == 'func(when=)' 331 | func_ts = func() 332 | assert (datetime.now() - func_ts).seconds < 1 333 | 334 | .. warning:: 335 | 336 | :paramref:`~forge.FParameter.default` and :paramref:`~forge.FParameter.factory` are mutually exclusive. 337 | Passing both will raise a :exc:`TypeError`. 338 | 339 | Supported by: 340 | 341 | - :term:`positional-only`: via :func:`forge.pos` 342 | - :term:`positional-or-keyword`: via :func:`forge.arg` and :func:`forge.pok` 343 | - :term:`keyword-only`: via :func:`forge.kwarg` and :func:`forge.kwo` 344 | 345 | 346 | Type annotation 347 | --------------- 348 | 349 | :class:`FParameters ` support type-hints by accepting a :paramref:`~forge.FParameter.type` keyword-argument: 350 | 351 | .. testcode:: 352 | 353 | import forge 354 | 355 | @forge.sign(forge.arg('myparam', type=int)) 356 | def func(myparam): 357 | return myparam 358 | 359 | assert forge.repr_callable(func) == 'func(myparam:int)' 360 | 361 | ``forge`` doesn't do anything with these type-hints, but there are a number of third party frameworks and packages out there that perform validation [#f1]_. 362 | 363 | .. note:: 364 | To provide a return-type annotation for a callable, use :class:`~forge.returns`. 365 | Review this revision and others in the :doc:`revision ` documentation. 366 | 367 | Supported by: 368 | 369 | - :term:`positional-only`: via :func:`forge.pos` 370 | - :term:`positional-or-keyword`: via :func:`forge.pok` and :func:`forge.arg` 371 | - :term:`var-positional`: via :data:`forge.vpo` and :func:`forge.args` 372 | - :term:`keyword-only`: via :func:`forge.kwo` and :func:`forge.kwarg` 373 | - :term:`var-keyword`: via :data:`forge.vkw` and :func:`forge.kwargs` 374 | 375 | 376 | Conversion 377 | ---------- 378 | 379 | :class:`FParameters ` support conversion for argument values by accepting a :paramref:`~forge.FParameter.converter` keyword-argument. 380 | This argument should either be a :term:`callable` that take three arguments: ``context``, ``name`` and ``value``, or an iterable of callables that accept those same arguments. 381 | ``Conversion`` functions must return the *converted value*. 382 | If :paramref:`~forge.FParameter.converter` is an iterable of :term:`callables `, the converters will be called in order. 383 | 384 | .. testcode:: 385 | 386 | def limit_to_max(ctx, name, value): 387 | if value > ctx.maximum: 388 | return ctx.maximum 389 | return value 390 | 391 | class MaxNumber: 392 | def __init__(self, maximum, capacity=0): 393 | self.maximum = maximum 394 | self.capacity = capacity 395 | 396 | @forge.sign(forge.self, forge.arg('value', converter=limit_to_max)) 397 | def set_capacity(self, value): 398 | self.capacity = value 399 | 400 | maxn = MaxNumber(1000) 401 | 402 | maxn.set_capacity(500) 403 | assert maxn.capacity == 500 404 | 405 | maxn.set_capacity(1500) 406 | assert maxn.capacity == 1000 407 | 408 | 409 | .. note:: 410 | 411 | While :class:`forge.vpo` and :class:`forge.vkw` (and their semantic counterparts :func:`forge.args` and :func:`forge.kwargs`) don't support default values, this is a convenient way to provide that same functionality. 412 | 413 | Supported by: 414 | 415 | - :term:`positional-only`: via :func:`forge.pos` 416 | - :term:`positional-or-keyword`: via :func:`forge.pok` and :func:`forge.arg` 417 | - :term:`var-positional`: via :data:`forge.vpo` and :func:`forge.args` 418 | - :term:`keyword-only`: via :func:`forge.kwo` and :func:`forge.kwarg` 419 | - :term:`var-keyword`: via :data:`forge.vkw` and :func:`forge.kwargs` 420 | 421 | 422 | Validation 423 | ---------- 424 | 425 | :class:`FParameters ` support validation for argument values by accepting a :paramref:`~forge.FParameter.validator` keyword-argument. 426 | This argument should either be a :term:`callable` that take three arguments: ``context``, ``name`` and ``value``, or an iterable of callables that accept those same arguments. 427 | ``Validation`` functions should raise an :exc:`Exception` upon validation failure. 428 | If :paramref:`~forge.FParameter.validator` is an iterable of :term:`callables `, the validaors will be called in order. 429 | 430 | .. testcode:: 431 | 432 | def validate_lte_max(ctx, name, value): 433 | if value > ctx.maximum: 434 | raise ValueError('{} is greater than {}'.format(value, ctx.maximum)) 435 | 436 | class MaxNumber: 437 | def __init__(self, maximum, capacity=0): 438 | self.maximum = maximum 439 | self.capacity = capacity 440 | 441 | @forge.sign(forge.self, forge.arg('value', validator=validate_lte_max)) 442 | def set_capacity(self, value): 443 | self.capacity = value 444 | 445 | maxn = MaxNumber(1000) 446 | 447 | maxn.set_capacity(500) 448 | assert maxn.capacity == 500 449 | 450 | raised = None 451 | try: 452 | maxn.set_capacity(1500) 453 | except ValueError as exc: 454 | raised = exc 455 | assert raised.args[0] == '1500 is greater than 1000' 456 | 457 | 458 | To use multiple validators, specify them in a ``list`` or ``tuple``: 459 | 460 | .. testcode:: 461 | 462 | import forge 463 | 464 | def validate_startswith_id(ctx, name, value): 465 | if not value.startswith('id'): 466 | raise ValueError("expected value beggining with 'id'") 467 | 468 | def validate_endswith_0(ctx, name, value): 469 | if not value.endswith('0'): 470 | raise ValueError("expected value ending with '0'") 471 | 472 | @forge.sign(forge.arg('id', validator=[validate_startswith_id, validate_endswith_0])) 473 | def stringify_id(id): 474 | return 'Your id is {}'.format(id) 475 | 476 | assert stringify_id('id100') == 'Your id is id100' 477 | 478 | raised = None 479 | try: 480 | stringify_id('id101') 481 | except ValueError as exc: 482 | raised = exc 483 | assert raised.args[0] == "expected value ending with '0'" 484 | 485 | 486 | .. note:: 487 | 488 | Validators can be enabled or disabled (they're automatically enabled) by passing a boolean to :func:`~forge.set_run_validators`. 489 | In addition, the current status of validation is available by calling :func:`~forge.get_run_validators`. 490 | 491 | Supported by: 492 | 493 | - :term:`positional-only`: via :func:`forge.pos` 494 | - :term:`positional-or-keyword`: via :func:`forge.pok` and :func:`forge.arg` 495 | - :term:`var-positional`: via :data:`forge.vpo` and :func:`forge.args` 496 | - :term:`keyword-only`: via :func:`forge.kwo` and :func:`forge.kwarg` 497 | - :term:`var-keyword`: via :data:`forge.vkw` and :func:`forge.kwargs` 498 | 499 | 500 | Binding 501 | ------- 502 | 503 | :class:`FParameters ` can be bound to a ``default`` value or factory by passing ``True`` as the keyword-argument :paramref:`~forge.FParameter.bound`. 504 | Bound parameters are not visible on the revised signature, but their default value is passed to the underlying callable. 505 | 506 | This is handy when creating utility functions that enable only a subset of callable's parameters. 507 | For example, to build a poor man's :mod:``requests``: 508 | 509 | .. testcode:: 510 | 511 | import urllib.request 512 | import forge 513 | 514 | @forge.copy(urllib.request.Request, exclude='self') 515 | def request(**kwargs): 516 | return urllib.request.urlopen(urllib.request.Request(**kwargs)) 517 | 518 | def with_method(method): 519 | revised = forge.modify('method', default=method, bound=True)(request) 520 | revised.__name__ = method.lower() 521 | return revised 522 | 523 | get = with_method('GET') 524 | post = with_method('POST') 525 | put = with_method('PUT') 526 | delete = with_method('DELETE') 527 | patch = with_method('PATCH') 528 | options = with_method('OPTIONS') 529 | head = with_method('HEAD') 530 | 531 | assert forge.repr_callable(request) == 'request(url, data=None, headers={}, origin_req_host=None, unverifiable=False, method=None)' 532 | assert forge.repr_callable(get) == 'get(url, data=None, headers={}, origin_req_host=None, unverifiable=False)' 533 | response = get('http://google.com') 534 | assert b'Feeling Lucky' in response.read() 535 | 536 | Supported by: 537 | 538 | - :term:`positional-only`: via :func:`forge.pos` 539 | - :term:`positional-or-keyword`: via :func:`forge.pok` and :func:`forge.arg` 540 | - :term:`keyword-only`: via :func:`forge.kwo` and :func:`forge.kwarg` 541 | 542 | 543 | Context 544 | ------- 545 | 546 | The first parameter in a :class:`~forge.FSignature` is allowed to be a ``context`` parameter; a special instance of :class:`~forge.FParameter` that is passed to ``converter`` and ``validator`` functions. 547 | For convenience, :data:`forge.self` and :data:`forge.cls` are already provided for use with instance methods and class methods, respectively. 548 | 549 | .. testcode:: 550 | 551 | import forge 552 | 553 | def with_prefix(ctx, name, value): 554 | return '{}{}'.format(ctx.prefix, value) 555 | 556 | class Prefixer: 557 | def __init__(self, prefix): 558 | self.prefix = prefix 559 | 560 | @forge.sign( 561 | forge.self, 562 | forge.arg('text', converter=with_prefix), 563 | ) 564 | def apply(self, text): 565 | return text 566 | 567 | prefixer = Prefixer('banana') 568 | assert prefixer.apply('berry') == 'bananaberry' 569 | 570 | .. note:: 571 | If you want to define a custom ``context`` variable for your signature, you can use :func:`forge.ctx` to create a :term:`positional-or-keyword` :class:`~forge.FParameter`. 572 | However, :func:`forge.ctx` has a more limited API than :func:`forge.arg`, so read the API documentation. 573 | 574 | 575 | Metadata 576 | -------- 577 | 578 | If you're the author of a third-party library that relies on ``forge`` you can take advantage of *parameter metadata*. 579 | 580 | Here are some tips for effective use of metadata: 581 | 582 | - Try making your metadata immutable. 583 | This keeps the entire :class:`~forge.FParameter` instance immutable. 584 | :paramref:`~forge.FParameter.metadata` is exposed as a :class:`MappingProxyView`, helping enforce immutability. 585 | 586 | - To avoid metadata key collisions, provide namespaced keys: 587 | 588 | .. testcode:: 589 | 590 | import forge 591 | 592 | MY_PREFIX = '__my_prefix' 593 | MY_KEY = '{}_mykey'.format(MY_PREFIX) 594 | 595 | @forge.sign(forge.arg('param', metadata={MY_KEY: 'value'})) 596 | def func(param): 597 | pass 598 | 599 | param = func.__mapper__.fsignature.parameters['param'] 600 | assert param.metadata == {MY_KEY: 'value'} 601 | 602 | Metadata should be composable, and namespacing is part of the solution. 603 | 604 | - Expose :class:`~forge.FParameter` wrappers for your specific metadata. 605 | While this can be challenging because of the special-use value :class:`forge.void`, a template function ``with_md`` is provided below: 606 | 607 | .. testcode:: 608 | 609 | import forge 610 | 611 | MY_PREFIX = '__my_prefix' 612 | MY_KEY = '{}_mykey'.format(MY_PREFIX) 613 | 614 | def update_metadata(ctx, name, value): 615 | return dict(value or {}, **{MY_KEY: 'myvalue'}) 616 | 617 | def with_md(constructor): 618 | fsig = forge.FSignature.from_callable(constructor) 619 | parameters = [] 620 | for name, param in fsig.parameters.items(): 621 | if name in ('default', 'factory', 'type'): 622 | parameters.append(param.replace( 623 | converter=lambda ctx, name, value: forge.empty, 624 | factory=lambda: forge.empty, 625 | )) 626 | elif name == 'metadata': 627 | parameters.append(param.replace(converter=update_metadata)) 628 | else: 629 | parameters.append(param) 630 | return forge.sign(*parameters)(constructor) 631 | 632 | md_arg = with_md(forge.arg) 633 | param = md_arg('x') 634 | assert param.metadata == {'__my_prefix_mykey': 'myvalue'} 635 | 636 | Supported by: 637 | 638 | - :term:`positional-only`: via :func:`forge.pos` 639 | - :term:`positional-or-keyword`: via :func:`forge.pok` and :func:`forge.arg` 640 | - :term:`var-positional`: via :data:`forge.vpo` and :func:`forge.args` 641 | - :term:`keyword-only`: via :func:`forge.kwo` and :func:`forge.kwarg` 642 | - :term:`var-keyword`: via :data:`forge.vkw` and :func:`forge.kwargs` 643 | 644 | 645 | Markers 646 | ======= 647 | 648 | ``forge`` has two ``marker`` classes – :class:`~forge.empty` and :class:`~forge.void`. 649 | These classes are used as default values to indicate non-input. 650 | While both have counterparts in the :mod:`inspect` module, they are different and are not interchangeable. 651 | 652 | Typically you won't need to use :class:`forge.empty` yourself, however the pattern referenced above for adding metadata to a :class:`~forge.FParameter` does require its use. 653 | 654 | :class:`~forge.void` is more useful, as it can help distinguish supplied arguments from default arguments: 655 | 656 | .. testcode:: 657 | 658 | import forge 659 | 660 | @forge.sign( 661 | forge.arg('a', default=forge.void), 662 | forge.arg('b', default=forge.void), 663 | forge.arg('c', default=forge.void), 664 | ) 665 | def func(**kwargs): 666 | return {k: v for k, v in kwargs.items() if v is not forge.void} 667 | 668 | assert forge.repr_callable(func) == 'func(a=, b=, c=)' 669 | assert func(b=2, c=3) == {'b': 2, 'c': 3} 670 | 671 | 672 | Utilities 673 | ========= 674 | 675 | findparam 676 | --------- 677 | 678 | :func:`forge.findparam` is a utility function for finding :class:`inspect.Parameter` instances or :class:`~forge.FParameter` instances in an iterable of parameters. 679 | 680 | The :paramref:`~forge.findparam.selector` argument must be a string, an iterbale of strings, or a callable that recieves a parameter and conditionally returns ``True`` if the parameter is a match. 681 | 682 | This is helpful when copying matching elements from a signature. 683 | For example, to copy all the keyword-only parameters from a function: 684 | 685 | .. testcode:: 686 | 687 | import forge 688 | 689 | func = lambda a, b, *, c, d: None 690 | kwo_iter = forge.findparam( 691 | forge.fsignature(func), 692 | lambda param: param.kind == forge.FParameter.KEYWORD_ONLY, 693 | ) 694 | assert [param.name for param in kwo_iter] == ['c', 'd'] 695 | 696 | 697 | callwith 698 | -------- 699 | 700 | :func:`forge.callwith` is a proxy function that takes a ``callable``, a ``named`` argument map, and an iterable of ``unnamed`` arguments, and performs a call to the ``callable`` with properly sorted and ordered arguments. 701 | Unlike in a typical function call, it is not necessary to properly order the arguments. 702 | This is an extremely helpful utility when you are providing an proxy to another function that has many :term:`positional-or-keyword` arguments. 703 | 704 | .. testcode:: 705 | 706 | import forge 707 | 708 | def func(a, b, c, d=4, e=5, f=6, *args): 709 | return (a, b, c, d, e, f, args) 710 | 711 | @forge.sign( 712 | forge.arg('a', default=1), 713 | forge.arg('b', default=2), 714 | forge.arg('c', default=3), 715 | *forge.args, 716 | ) 717 | def func2(*args, **kwargs): 718 | return forge.callwith(func, kwargs, args) 719 | 720 | assert forge.repr_callable(func2) == 'func2(a=1, b=2, c=3, *args)' 721 | assert func2(10, 20, 30, 'a', 'b', 'c') == (10, 20, 30, 4, 5, 6, ('a', 'b', 'c')) 722 | 723 | An alternative implementation not using :func:`forge.callwith`, would look like this: 724 | 725 | .. testcode:: 726 | 727 | import forge 728 | 729 | def func(a, b, c, d=4, e=5, f=6, *args): 730 | return (a, b, c, d, e, f, args) 731 | 732 | @forge.sign( 733 | forge.arg('a', default=1), 734 | forge.arg('b', default=2), 735 | forge.arg('c', default=3), 736 | *forge.args, 737 | ) 738 | def func2(*args, **kwargs): 739 | return func( 740 | kwargs['a'], 741 | kwargs['b'], 742 | kwargs['c'], 743 | 4, 744 | 5, 745 | 6, 746 | *args, 747 | ) 748 | 749 | assert forge.repr_callable(func2) == 'func2(a=1, b=2, c=3, *args)' 750 | assert func2(10, 20, 30, 'a', 'b', 'c') == (10, 20, 30, 4, 5, 6, ('a', 'b', 'c')) 751 | 752 | Using :func:`forge.callwith` therefore requires less precision, boilerplate and maintenance. 753 | 754 | 755 | repr_callable 756 | ------------- 757 | 758 | :func:`~forge.repr_callable` takes a :term:`callable` and pretty-prints the function's qualified name, its parameters, and its return type annotation. 759 | 760 | It's used extensively in the documentation to surface the resultant signature after a revision. 761 | 762 | 763 | **** 764 | 765 | .. rubric:: Footnotes 766 | 767 | .. [#f1] `typeguard `_: Run-time type checker for Python 768 | -------------------------------------------------------------------------------- /forge/__init__.py: -------------------------------------------------------------------------------- 1 | from ._config import ( 2 | get_run_validators, 3 | set_run_validators, 4 | ) 5 | from ._exceptions import ( 6 | ForgeError, 7 | ImmutableInstanceError, 8 | ) 9 | from ._marker import ( 10 | empty, 11 | void, 12 | ) 13 | from ._revision import ( 14 | Mapper, 15 | Revision, 16 | # Group 17 | compose, 18 | copy, 19 | manage, 20 | returns, 21 | sort, 22 | synthesize, sign, 23 | # Unit 24 | delete, 25 | insert, 26 | modify, 27 | replace, 28 | translocate, move, 29 | ) 30 | from ._signature import ( 31 | Factory, 32 | FParameter, 33 | FSignature, 34 | findparam, 35 | fsignature, 36 | pos, pok, vpo, kwo, vkw, 37 | arg, ctx, args, kwarg, kwargs, 38 | self_ as self, 39 | cls_ as cls, 40 | ) 41 | from ._utils import ( 42 | callwith, 43 | repr_callable, 44 | ) 45 | -------------------------------------------------------------------------------- /forge/_config.py: -------------------------------------------------------------------------------- 1 | _run_validators = True 2 | 3 | 4 | def get_run_validators() -> bool: 5 | """ 6 | Check whether validators are enabled. 7 | :returns: whether or not validators are run. 8 | """ 9 | return _run_validators 10 | 11 | 12 | def set_run_validators(run: bool) -> None: 13 | """ 14 | Set whether or not validators are enabled. 15 | :param run: whether the validators are run 16 | """ 17 | # pylint: disable=W0603, global-statement 18 | if not isinstance(run, bool): 19 | raise TypeError("'run' must be bool.") 20 | global _run_validators 21 | _run_validators = run 22 | -------------------------------------------------------------------------------- /forge/_counter.py: -------------------------------------------------------------------------------- 1 | class Counter: 2 | """ 3 | A counter whose instances provides an incremental value when called 4 | 5 | :ivar count: the next index for creation. 6 | """ 7 | __slots__ = ('count',) 8 | 9 | def __init__(self): 10 | self.count = 0 11 | 12 | def __call__(self): 13 | count = self.count 14 | self.count += 1 15 | return count 16 | 17 | 18 | class CreationOrderMeta(type): 19 | """ 20 | A metaclass that assigns a `_creation_order` to class instances 21 | """ 22 | def __call__(cls, *args, **kwargs): 23 | ins = super().__call__(*args, **kwargs) 24 | object.__setattr__(ins, '_creation_order', ins._creation_counter()) 25 | return ins 26 | 27 | def __new__(mcs, name, bases, namespace): 28 | namespace['_creation_counter'] = Counter() 29 | return super().__new__(mcs, name, bases, namespace) -------------------------------------------------------------------------------- /forge/_exceptions.py: -------------------------------------------------------------------------------- 1 | class ForgeError(Exception): 2 | """ 3 | A common base class for ``forge`` exceptions 4 | """ 5 | pass 6 | 7 | 8 | class ImmutableInstanceError(ForgeError): 9 | """ 10 | An error that is raised when trying to set an attribute on a 11 | :class:`~forge._immutable.Immutable` instance. 12 | """ 13 | pass -------------------------------------------------------------------------------- /forge/_immutable.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from forge._exceptions import ImmutableInstanceError 4 | 5 | 6 | def asdict(obj) -> typing.Dict: 7 | """ 8 | Provides a "look" into any Python class instance by returning a dict 9 | into the attribute or slot values. 10 | 11 | :param obj: any Python class instance 12 | :returns: the attribute or slot values from :paramref:`.asdict.obj` 13 | """ 14 | if hasattr(obj, '__dict__'): 15 | return { 16 | k: v for k, v in obj.__dict__.items() 17 | if not k.startswith('_') 18 | } 19 | 20 | return { 21 | k: getattr(obj, k) for k in obj.__slots__ 22 | if not k.startswith('_') 23 | } 24 | 25 | 26 | def replace(obj, **changes): 27 | """ 28 | Return a new object replacing specified fields with new values. 29 | class Klass(Immutable): 30 | def __init__(self, value): 31 | # in lieu of: self.value = value 32 | object.__setattr__(self, 'value', value) 33 | 34 | k1 = Klass(1) 35 | k2 = replace(k1, value=2) 36 | assert (k1.value, k2.value) == (1, 2) 37 | 38 | :obj: any object who's ``__init__`` method simply writes arguments to 39 | instance variables 40 | :changes: an attribute:argument mapping that will replace instance variables 41 | on the current instance 42 | """ 43 | return type(obj)(**dict(asdict(obj), **changes)) 44 | 45 | 46 | class Immutable: 47 | """ 48 | A class whose instances lack a ``__setattr__`` method, making them 99% 49 | immutable. It's still possible to manipulate the instance variables in 50 | other ways (as Python doesn't support real immutability outside of 51 | :class:`collections.namedtuple` or :types.`NamedTuple`). 52 | 53 | :param kwargs: an attribute:argument mapping that are set on the instance 54 | """ 55 | __slots__ = () 56 | 57 | def __init__(self, **kwargs): 58 | for k, v in kwargs.items(): 59 | object.__setattr__(self, k, v) 60 | 61 | def __eq__(self, other: typing.Any) -> bool: 62 | if not isinstance(other, type(self)): 63 | return False 64 | return asdict(other) == asdict(self) 65 | 66 | def __getattr__(self, key: str) -> typing.Any: 67 | """ 68 | Solely for placating mypy. 69 | Not particularly impressed with this hack but it saves a lot of 70 | `#type: ignore` effort elsewhere 71 | """ 72 | return super().__getattribute__(key) 73 | 74 | def __setattr__(self, key: str, value: typing.Any): 75 | """ 76 | Method exists to inhibit functionality of :func:`setattr` 77 | 78 | :param key: ignored - can't set attributes 79 | :param value: ignored - can't set attributes 80 | :raises ImmutableInstanceError: attributes cannot be set on an 81 | Immutable instance 82 | """ 83 | raise ImmutableInstanceError("cannot assign to field '{}'".format(key)) 84 | -------------------------------------------------------------------------------- /forge/_marker.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import typing 3 | 4 | # pylint: disable=C0103, invalid-name 5 | 6 | 7 | class MarkerMeta(type): 8 | """ 9 | A metaclass that creates marker classes for use as distinguishing elements 10 | in a signature. 11 | """ 12 | def __repr__(cls) -> str: 13 | return '<{}>'.format(cls.__name__) 14 | 15 | def __new__( 16 | mcs, 17 | name: str, 18 | bases: typing.Tuple[type, ...], 19 | namespace: typing.Dict[str, typing.Any], 20 | ): 21 | """ 22 | Create a new ``forge`` marker class with a ``native`` attribute. 23 | 24 | :param name: the name of the new class 25 | :param bases: the base classes of the new class 26 | :param namespace: the namespace of the new class 27 | :param native: the ``native`` Python marker class 28 | """ 29 | namespace['__repr__'] = lambda self: repr(type(self)) 30 | return super().__new__(mcs, name, bases, namespace) 31 | 32 | 33 | class void(metaclass=MarkerMeta): 34 | """ 35 | A simple :class:`~forge.marker.MarkerMeta` class useful for denoting that 36 | no input was suplied. 37 | 38 | Usage:: 39 | 40 | def proxy(a, b, extra=void): 41 | if extra is not void: 42 | return proxied(a, b) 43 | return proxied(a, b, c=extra) 44 | """ 45 | pass 46 | 47 | _void = void() 48 | """Internal-use void instance""" 49 | 50 | 51 | class empty(metaclass=MarkerMeta): 52 | """ 53 | A simple :class:`~forge.marker.MarkerMeta` class useful for denoting that 54 | no input was suplied. Used in place of :class:`inspect.Parameter.empty` 55 | as that is not repr'd (providing confusing usage). 56 | 57 | Usage:: 58 | 59 | def proxy(a, b, extra=empty): 60 | if extra is not empty: 61 | return proxied(a, b) 62 | return proxied(a, b, c=inspect.Parameter.empty) 63 | """ 64 | native = inspect.Parameter.empty 65 | 66 | @classmethod 67 | def ccoerce_native(cls, value): 68 | """ 69 | Conditionally coerce the value to a non-:class:`~forge.empty` value. 70 | 71 | :param value: the value to conditionally coerce 72 | :returns: the value, if the value is not an instance of 73 | :class:`~forge.empty`, otherwise return 74 | :class:`inspect.Paramter.empty` 75 | """ 76 | return value if value is not cls else cls.native 77 | 78 | @classmethod 79 | def ccoerce_synthetic(cls, value): 80 | """ 81 | Conditionally coerce the value to a 82 | non-:class:`inspect.Parameter.empty` value. 83 | 84 | :param value: the value to conditionally coerce 85 | :returns: the value, if the value is not an instance of 86 | :class:`inspect.Paramter.empty`, otherwise return 87 | :class:`~forge.empty` 88 | """ 89 | return value if value is not cls.native else cls 90 | -------------------------------------------------------------------------------- /forge/_revision.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import inspect 4 | import types 5 | import typing 6 | 7 | import forge._immutable as immutable 8 | from forge._marker import _void, empty 9 | from forge._signature import ( 10 | _TYPE_FINDITER_SELECTOR, 11 | FParameter, 12 | FSignature, 13 | fsignature, 14 | findparam, 15 | _get_pk_string, 16 | get_context_parameter, 17 | get_var_keyword_parameter, 18 | get_var_positional_parameter, 19 | ) 20 | from forge._utils import CallArguments 21 | 22 | 23 | class Mapper(immutable.Immutable): 24 | """ 25 | An immutable data structure that provides the recipe for mapping 26 | an :class:`~forge.FSignature` to an underlying callable. 27 | 28 | :param fsignature: an instance of :class:`~forge.FSignature` that provides 29 | the public and private interface. 30 | :param callable: a callable that ultimately receives the arguments provided 31 | to public :class:`~forge.FSignature` interface. 32 | 33 | :ivar callable: see :paramref:`~forge._signature.Mapper.callable` 34 | :ivar fsignature: see :paramref:`~forge._signature.Mapper.fsignature` 35 | :ivar parameter_map: a :class:`types.MappingProxy` that exposes the strategy 36 | of how to map from the :paramref:`.Mapper.fsignature` to the 37 | :paramref:`.Mapper.callable` 38 | :ivar private_signature: a cached copy of 39 | :paramref:`~forge._signature.Mapper.callable`'s 40 | :class:`inspect.Signature` 41 | :ivar public_signature: a cached copy of 42 | :paramref:`~forge._signature.Mapper.fsignature`'s manifest as a 43 | :class:`inspect.Signature` 44 | """ 45 | __slots__ = ( 46 | 'callable', 47 | 'context_param', 48 | 'fsignature', 49 | 'parameter_map', 50 | 'private_signature', 51 | 'public_signature', 52 | ) 53 | 54 | def __init__( 55 | self, 56 | fsignature: FSignature, 57 | callable: typing.Callable[..., typing.Any], 58 | ) -> None: 59 | # pylint: disable=W0622, redefined-builtin 60 | # pylint: disable=W0621, redefined-outer-name 61 | private_signature = inspect.signature(callable) 62 | public_signature = fsignature.native 63 | parameter_map = self.map_parameters(fsignature, private_signature) 64 | context_param = get_context_parameter(fsignature) 65 | 66 | super().__init__( 67 | callable=callable, 68 | context_param=context_param, 69 | fsignature=fsignature, 70 | private_signature=private_signature, 71 | public_signature=public_signature, 72 | parameter_map=parameter_map, 73 | ) 74 | 75 | def __call__( 76 | self, 77 | *args: typing.Any, 78 | **kwargs: typing.Any 79 | ) -> CallArguments: 80 | """ 81 | Maps the arguments from the :paramref:`~forge.Mapper.public_signature` 82 | to the :paramref:`~forge.Mapper.private_signature`. 83 | 84 | Follows the strategy: 85 | 86 | #. bind the arguments to the :paramref:`~forge.Mapper.public_signature` 87 | #. partialy bind the :paramref:`~forge.Mapper.private_signature` 88 | #. identify the context argument (if one exists) from 89 | :class:`~forge.FParameter`s on the :class:`~forge.FSignature` 90 | #. iterate over the intersection of bound arguments and ``bound`` \ 91 | parameters on the :paramref:`.Mapper.fsignature` to the \ 92 | :paramref:`~forge.Mapper.private_signature` of the \ 93 | :paramref:`.Mapper.callable`, getting their transformed value by \ 94 | calling :meth:`~forge.FParameter.__call__` 95 | #. map the resulting value into the private_signature bound arguments 96 | #. generate and return a :class:`~forge._signature.CallArguments` from \ 97 | the private_signature bound arguments. 98 | 99 | :param args: the positional arguments to map 100 | :param kwargs: the keyword arguments to map 101 | :returns: transformed :paramref:`~forge.Mapper.__call__.args` and 102 | :paramref:`~forge.Mapper.__call__.kwargs` mapped from 103 | :paramref:`~forge.Mapper.public_signature` to 104 | :paramref:`~forge.Mapper.private_signature` 105 | """ 106 | try: 107 | public_ba = self.public_signature.bind(*args, **kwargs) 108 | except TypeError as exc: 109 | raise TypeError( 110 | '{callable_name}() {message}'.\ 111 | format( 112 | callable_name=self.callable.__name__, 113 | message=exc.args[0], 114 | ), 115 | ) 116 | public_ba.apply_defaults() 117 | 118 | private_ba = self.private_signature.bind_partial() 119 | private_ba.apply_defaults() 120 | ctx = self.get_context(public_ba.arguments) 121 | 122 | for from_name, from_param in self.fsignature.parameters.items(): 123 | from_val = public_ba.arguments.get(from_name, empty) 124 | to_name = self.parameter_map[from_name] 125 | to_param = self.private_signature.parameters[to_name] 126 | to_val = self.fsignature.parameters[from_name](ctx, from_val) 127 | 128 | if to_param.kind is FParameter.VAR_POSITIONAL: 129 | # e.g. f(*args) -> g(*args) 130 | private_ba.arguments[to_name] = to_val 131 | elif to_param.kind is FParameter.VAR_KEYWORD: 132 | if from_param.kind is FParameter.VAR_KEYWORD: 133 | # e.g. f(**kwargs) -> g(**kwargs) 134 | private_ba.arguments[to_name].update(to_val) 135 | else: 136 | # e.g. f(a) -> g(**kwargs) 137 | private_ba.arguments[to_name]\ 138 | [from_param.interface_name] = to_val 139 | else: 140 | # e.g. f(a) -> g(a) 141 | private_ba.arguments[to_name] = to_val 142 | 143 | return CallArguments.from_bound_arguments(private_ba) 144 | 145 | def __repr__(self) -> str: 146 | pubstr = str(self.public_signature) 147 | privstr = str(self.private_signature) 148 | return '<{} {} => {}>'.format(type(self).__name__, pubstr, privstr) 149 | 150 | def get_context(self, arguments: typing.Mapping) -> typing.Any: 151 | """ 152 | Retrieves the context arguments value (if a context parameter exists) 153 | 154 | :param arguments: a mapping of parameter names to argument values 155 | :returns: the argument value for the context parameter (if it exists), 156 | otherwise ``None``. 157 | """ 158 | return arguments[self.context_param.name] \ 159 | if self.context_param \ 160 | else None 161 | 162 | @staticmethod 163 | def map_parameters( 164 | from_: FSignature, 165 | to_: inspect.Signature, 166 | ) -> types.MappingProxyType: 167 | ''' 168 | Build a mapping of parameters from the 169 | :paramref:`.Mapper.map_parameters.from_` to the 170 | :paramref:`.Mapper.map_parameters.to_`. 171 | 172 | Strategy rules: 173 | #. every *to_* :term:`positional-only` must be mapped to 174 | #. every *to_* :term:`positional-or-keyword` w/o default must be 175 | mapped to 176 | #. every *to_* :term:`keyword-only` w/o default must be mapped to 177 | #. *from_* :term:`var-positional` requires *to_* :term:`var-positional` 178 | #. *from_* :term:`var-keyword` requires *to_* :term:`var-keyword` 179 | 180 | :param from_: the :class:`~forge.FSignature` to map from 181 | :param to_: the :class:`inspect.Signature` to map to 182 | :returns: a :class:`types.MappingProxyType` that shows how arguments 183 | are mapped. 184 | ''' 185 | # pylint: disable=W0622, redefined-builtin 186 | from_vpo_param = get_var_positional_parameter(from_) 187 | from_vkw_param = get_var_keyword_parameter(from_) 188 | from_param_index = { 189 | fparam.interface_name: fparam for fparam in from_ 190 | if fparam not in (from_vpo_param, from_vkw_param) 191 | } 192 | 193 | to_vpo_param = \ 194 | get_var_positional_parameter(to_.parameters.values()) 195 | to_vkw_param = \ 196 | get_var_keyword_parameter(to_.parameters.values()) 197 | to_param_index = { 198 | param.name: param for param in to_.parameters.values() 199 | if param not in (to_vpo_param, to_vkw_param) 200 | } 201 | 202 | mapping = {} 203 | for name in list(to_param_index): 204 | param = to_param_index.pop(name) 205 | try: 206 | param_t = from_param_index.pop(name) 207 | except KeyError: 208 | # masked mapping, e.g. f() -> g(a=1) 209 | if param.default is not empty.native: 210 | continue 211 | 212 | # invalid mapping, e.g. f() -> g(a) 213 | kind_repr = _get_pk_string(param.kind) 214 | raise TypeError( 215 | "Missing requisite mapping to non-default {kind_repr} " 216 | "parameter '{pri_name}'".\ 217 | format(kind_repr=kind_repr, pri_name=name) 218 | ) 219 | else: 220 | mapping[param_t.name] = name 221 | 222 | if from_vpo_param: 223 | # invalid mapping, e.g. f(*args) -> g() 224 | if not to_vpo_param: 225 | kind_repr = _get_pk_string(FParameter.VAR_POSITIONAL) 226 | raise TypeError( 227 | "Missing requisite mapping from {kind_repr} parameter " 228 | "'{from_vpo_param.name}'".\ 229 | format(kind_repr=kind_repr, from_vpo_param=from_vpo_param) 230 | ) 231 | # var-positional mapping, e.g. f(*args) -> g(*args) 232 | mapping[from_vpo_param.name] = to_vpo_param.name 233 | 234 | if from_vkw_param: 235 | # invalid mapping, e.g. f(**kwargs) -> g() 236 | if not to_vkw_param: 237 | kind_repr = _get_pk_string(FParameter.VAR_KEYWORD) 238 | raise TypeError( 239 | "Missing requisite mapping from {kind_repr} parameter " 240 | "'{from_vkw_param.name}'".\ 241 | format(kind_repr=kind_repr, from_vkw_param=from_vkw_param) 242 | ) 243 | # var-keyword mapping, e.g. f(**kwargs) -> g(**kwargs) 244 | mapping[from_vkw_param.name] = to_vkw_param.name 245 | 246 | if from_param_index: 247 | # invalid mapping, e.g. f(a) -> g() 248 | if not to_vkw_param: 249 | raise TypeError( 250 | "Missing requisite mapping from parameters ({})".format( 251 | ', '.join([pt.name for pt in from_param_index.values()]) 252 | ) 253 | ) 254 | # to-var-keyword mapping, e.g. f(a) -> g(**kwargs) 255 | for param_t in from_param_index.values(): 256 | mapping[param_t.name] = to_vkw_param.name 257 | 258 | return types.MappingProxyType(mapping) 259 | 260 | 261 | class Revision: 262 | """ 263 | This is a base class for other revisions. 264 | It implements two methods of primary importance: 265 | :meth:`~forge.Revision.revise` and :meth:`~forge.Revision.__call__`. 266 | 267 | Revisions can act as decorators, in which case the callable is wrapped in 268 | a function that translates the supplied arguments to the parameters the 269 | underlying callable expects:: 270 | 271 | import forge 272 | 273 | @forge.Revision() 274 | def myfunc(): 275 | pass 276 | 277 | Revisions can also operate on :class:`~forge.FSignature` instances 278 | directly by providing an ``FSignature`` to :meth:`~forge.Revision.revise`:: 279 | 280 | import forge 281 | 282 | in_ = forge.FSignature() 283 | out_ = forge.Revision().revise(in_) 284 | assert in_ == out_ 285 | 286 | The :meth:`~forge.Revision.revise` method is expected to return an instance 287 | of :class:`~forge.FSignature` that **is not validated**. This can be 288 | achieved by supplying ``__validate_attributes__=False`` to either 289 | :class:`~forge.FSignature` or :meth:`~forge.FSignature.replace`. 290 | 291 | Instances of :class:`~forge.Revision` don't have any initialization 292 | parameters or public attributes, but subclasses instances often do. 293 | """ 294 | def __call__( 295 | self, 296 | callable: typing.Callable[..., typing.Any] 297 | ) -> typing.Callable[..., typing.Any]: 298 | """ 299 | Wraps a callable with a function that maps the new signature's 300 | parameters to the original function's signature. 301 | 302 | If the function was already wrapped (has an :attr:`__mapper__` 303 | attribute), then the (underlying) wrapped function is re-wrapped. 304 | 305 | :param callable: a :term:`callable` whose signature to revise 306 | :returns: a function with the revised signature that calls into the 307 | provided :paramref:`~forge.Revision.__call__.callable` 308 | """ 309 | # pylint: disable=W0622, redefined-builtin 310 | if hasattr(callable, '__mapper__'): 311 | next_ = self.revise(callable.__mapper__.fsignature) # type: ignore 312 | callable = callable.__wrapped__ # type: ignore 313 | else: 314 | next_ = self.revise(FSignature.from_callable(callable)) 315 | 316 | # Unrevised; not wrapped 317 | if asyncio.iscoroutinefunction(callable): 318 | @functools.wraps(callable) 319 | async def inner(*args, **kwargs): 320 | # pylint: disable=E1102, not-callable 321 | mapped = inner.__mapper__(*args, **kwargs) 322 | return await callable(*mapped.args, **mapped.kwargs) 323 | else: 324 | @functools.wraps(callable) # type: ignore 325 | def inner(*args, **kwargs): 326 | # pylint: disable=E1102, not-callable 327 | mapped = inner.__mapper__(*args, **kwargs) 328 | return callable(*mapped.args, **mapped.kwargs) 329 | 330 | next_.validate() 331 | inner.__mapper__ = Mapper(next_, callable) # type: ignore 332 | inner.__signature__ = inner.__mapper__.public_signature # type: ignore 333 | return inner 334 | 335 | def revise(self, previous: FSignature) -> FSignature: 336 | """ 337 | Applies the identity revision: ``previous`` is returned unmodified. 338 | 339 | No validation is performed on the updated :class:`~forge.FSignature`, 340 | allowing it to be used as an intermediate revision in the context of 341 | :class:`~forge.compose`. 342 | 343 | :param previous: the :class:`~forge.FSignature` to modify 344 | :returns: a modified instance of :class:`~forge.FSignature` 345 | """ 346 | # pylint: disable=R0201, no-self-use 347 | return previous 348 | 349 | 350 | ## Group Revisions 351 | class compose(Revision): # pylint: disable=C0103, invalid-name 352 | """ 353 | Batch revision that takes :class:`~forge.Revision` instances and applies 354 | their :meth:`~forge.Revision.revise` using :func:`functools.reduce`. 355 | 356 | :param revisions: instances of :class:`~forge.Revision`, used to revise 357 | the :class:`~forge.FSignature`. 358 | """ 359 | def __init__(self, *revisions): 360 | for rev in revisions: 361 | if not isinstance(rev, Revision): 362 | raise TypeError("received non-revision '{}'".format(rev)) 363 | self.revisions = revisions 364 | 365 | def revise(self, previous: FSignature) -> FSignature: 366 | """ 367 | Applies :paramref:`~forge.compose.revisions` 368 | 369 | No validation is explicitly performed on the updated 370 | :class:`~forge.FSignature`, allowing it to be used as an intermediate 371 | revision in the context of (another) :class:`~forge.compose`. 372 | 373 | :param previous: the :class:`~forge.FSignature` to modify 374 | :returns: a modified instance of :class:`~forge.FSignature` 375 | """ 376 | return functools.reduce( 377 | lambda previous, revision: revision.revise(previous), 378 | self.revisions, 379 | previous, 380 | ) 381 | 382 | 383 | class copy(Revision): # pylint: disable=C0103, invalid-name 384 | """ 385 | The ``copy`` revision takes a :term:`callable` and optionally parameters to 386 | include or exclude, and applies the resultant signature. 387 | 388 | :param callable: a callable whose signature is copied 389 | :param include: a string, iterable of strings, or a function that receives 390 | an instance of :class:`~forge.FParameter` and returns a truthy value 391 | whether to include it. 392 | :param exclude: a string, iterable of strings, or a function that receives 393 | an instance of :class:`~forge.FParameter` and returns a truthy value 394 | whether to exclude it. 395 | :raises TypeError: if ``include`` and ``exclude`` are provided 396 | """ 397 | def __init__( 398 | self, 399 | callable: typing.Callable[..., typing.Any], 400 | *, 401 | include: typing.Optional[_TYPE_FINDITER_SELECTOR] = None, 402 | exclude: typing.Optional[_TYPE_FINDITER_SELECTOR] = None 403 | ) -> None: 404 | # pylint: disable=W0622, redefined-builtin 405 | if include is not None and exclude is not None: 406 | raise TypeError( 407 | "expected 'include', 'exclude', or neither, but received both" 408 | ) 409 | 410 | self.signature = fsignature(callable) 411 | self.include = include 412 | self.exclude = exclude 413 | 414 | def revise(self, previous: FSignature) -> FSignature: 415 | """ 416 | Copies the signature of :paramref:`~forge.copy.callable`. 417 | If provided, only a subset of parameters are copied, as determiend by 418 | :paramref:`~forge.copy.include` and :paramref:`~forge.copy.exclude`. 419 | 420 | Unlike most subclasses of :class:`~forge.Revision`, validation is 421 | performed on the updated :class:`~forge.FSignature`. 422 | This is because :class:`~forge.copy` takes a :term:`callable` which 423 | is required by Python to have a valid signature, so it's impossible 424 | to return an invalid signature. 425 | 426 | :param previous: the :class:`~forge.FSignature` to modify 427 | :returns: a modified instance of :class:`~forge.FSignature` 428 | """ 429 | if self.include: 430 | return self.signature.replace(parameters=list( 431 | findparam(self.signature, self.include) 432 | )) 433 | elif self.exclude: 434 | excluded = list(findparam(self.signature, self.exclude)) 435 | return self.signature.replace(parameters=[ 436 | param for param in self.signature if param not in excluded 437 | ]) 438 | return self.signature 439 | 440 | 441 | class manage(Revision): # pylint: disable=C0103, invalid-name 442 | """ 443 | Revision that revises a :class:`~forge.FSignature` with a user-supplied 444 | revision function. 445 | 446 | .. testcode:: 447 | 448 | import forge 449 | 450 | def reverse(previous): 451 | return previous.replace( 452 | parameters=previous[::-1], 453 | __validate_parameters__=False, 454 | ) 455 | 456 | @forge.manage(reverse) 457 | def func(a, b, c): 458 | pass 459 | 460 | assert forge.repr_callable(func) == 'func(c, b, a)' 461 | 462 | :param callable: a callable that alters the previous signature 463 | """ 464 | def __init__( 465 | self, 466 | callable: typing.Callable[[FSignature], FSignature] 467 | ) -> None: 468 | # pylint: disable=W0622, redefined-builtin 469 | self.callable = callable 470 | 471 | def revise(self, previous: FSignature) -> FSignature: 472 | """ 473 | Passes the signature to :paramref:`~forge.manage.callable` for 474 | revision. 475 | 476 | .. warning:: 477 | 478 | No validation is typically performed in the :attr:`revise` method. 479 | Consider providing `False` as an argument value to 480 | :paramref:`~forge.FSignature.__validate_parameters__`, so that this 481 | revision can be used within the context of a 482 | :class:`~forge.compose` revision. 483 | 484 | :param previous: the :class:`~forge.FSignature` to modify 485 | :returns: a modified instance of :class:`~forge.FSignature` 486 | """ 487 | return self.callable(previous) 488 | 489 | 490 | class returns(Revision): # pylint: disable=invalid-name 491 | """ 492 | The ``returns`` revision updates a signature's ``return-type`` annotation. 493 | 494 | .. testcode:: 495 | 496 | import forge 497 | 498 | @forge.returns(int) 499 | def x(): 500 | pass 501 | 502 | assert forge.repr_callable(x) == "x() -> int" 503 | 504 | :param type: the ``return type`` for the factory 505 | :ivar return_annotation: the ``return type`` used for revising signatures 506 | """ 507 | 508 | def __init__(self, type: typing.Any = empty) -> None: 509 | # pylint: disable=W0622, redefined-builtin 510 | self.return_annotation = type 511 | 512 | def __call__( 513 | self, 514 | callable: typing.Callable[..., typing.Any] 515 | ) -> typing.Callable[..., typing.Any]: 516 | """ 517 | Changes the return value of the supplied callable. 518 | If the callable is already revised (has an 519 | :attr:`__mapper__` attribute), then the ``return type`` annoation is 520 | set without wrapping the function. 521 | Otherwise, the :attr:`__mapper__` and :attr:`__signature__` are updated 522 | 523 | :param callable: see :paramref:`~forge.Revision.__call__.callable` 524 | :returns: either the input callable with an updated return type 525 | annotation, or a wrapping function with the appropriate return type 526 | annotation as determined by the strategy described above. 527 | """ 528 | # pylint: disable=W0622, redefined-builtin 529 | if hasattr(callable, '__mapper__'): 530 | return super().__call__(callable) 531 | 532 | elif hasattr(callable, '__signature__'): 533 | sig = callable.__signature__ # type: ignore 534 | callable.__signature__ = sig.replace( # type: ignore 535 | return_annotation=self.return_annotation 536 | ) 537 | 538 | else: 539 | callable.__annotations__['return'] = self.return_annotation 540 | 541 | return callable 542 | 543 | def revise(self, previous: FSignature) -> FSignature: 544 | """ 545 | Applies the return type annotation, 546 | :paramref:`~forge.returns.return_annotation`, to the input signature. 547 | 548 | No validation is performed on the updated :class:`~forge.FSignature`, 549 | allowing it to be used as an intermediate revision in the context of 550 | :class:`~forge.compose`. 551 | 552 | :param previous: the :class:`~forge.FSignature` to modify 553 | :returns: a modified instance of :class:`~forge.FSignature` 554 | """ 555 | return FSignature( 556 | previous, 557 | return_annotation=self.return_annotation, 558 | ) 559 | 560 | 561 | class synthesize(Revision): # pylint: disable=C0103, invalid-name 562 | """ 563 | Revision that builds a new signature from instances of 564 | :class:`~forge.FParameter` 565 | 566 | Order parameters with the following strategy: 567 | 568 | #. arguments are returned in order 569 | #. keyword arguments are sorted by ``_creation_order``, and evolved with \ 570 | the ``keyword`` value as the name and interface_name (if not set). 571 | 572 | .. warning:: 573 | 574 | When supplying previously-created parameters to :func:`~forge.sign`, 575 | those parameters will be ordered by their creation order. 576 | 577 | This is because Python implementations prior to ``3.7`` don't 578 | guarantee the ordering of keyword-arguments. 579 | 580 | Therefore, it is recommended that when supplying pre-created 581 | parameters to :func:`~forge.sign`, you supply them as positional 582 | arguments: 583 | 584 | .. testcode:: 585 | 586 | import forge 587 | 588 | param_b = forge.arg('b') 589 | param_a = forge.arg('a') 590 | 591 | @forge.sign(a=param_a, b=param_b) 592 | def func1(**kwargs): 593 | pass 594 | 595 | @forge.sign(param_a, param_b) 596 | def func2(**kwargs): 597 | pass 598 | 599 | assert forge.repr_callable(func1) == 'func1(b, a)' 600 | assert forge.repr_callable(func2) == 'func2(a, b)' 601 | 602 | :param parameters: :class:`~forge.FParameter` instances to be ordered 603 | :param named_parameters: :class:`~forge.FParameter` instances to be 604 | ordered, updated 605 | :returns: a wrapping factory that takes a callable and returns a wrapping 606 | function that has a signature as defined by the 607 | :paramref:`~forge.synthesize..parameters` and 608 | :paramref:`~forge.synthesize.named_parameters` 609 | """ 610 | def __init__(self, *parameters, **named_parameters): 611 | self.parameters = [ 612 | *parameters, 613 | *[ 614 | param.replace( 615 | name=name, 616 | interface_name=param.interface_name or name, 617 | ) for name, param in sorted( 618 | named_parameters.items(), 619 | key=lambda i: i[1]._creation_order, 620 | ) 621 | ] 622 | ] 623 | 624 | def revise(self, previous: FSignature) -> FSignature: 625 | """ 626 | Produces a signature with the parameters provided at initialization. 627 | 628 | No validation is performed on the updated :class:`~forge.FSignature`, 629 | allowing it to be used as an intermediate revision in the context of 630 | :class:`~forge.compose`. 631 | 632 | :param previous: the :class:`~forge.FSignature` to modify 633 | :returns: a modified instance of :class:`~forge.FSignature` 634 | """ 635 | return previous.replace( # type: ignore 636 | parameters=self.parameters, 637 | __validate_parameters__=False, 638 | ) 639 | 640 | # Convenience name 641 | sign = synthesize # pylint: disable=C0103, invalid-name 642 | 643 | 644 | class sort(Revision): # pylint: disable=C0103, invalid-name 645 | """ 646 | Revision that orders parameters. The default orders parameters ina common- 647 | sense way: 648 | 649 | #. :term:`parameter kind`, then 650 | #. parameters having a default value 651 | #. parameter name lexicographically 652 | 653 | .. testcode:: 654 | 655 | import forge 656 | 657 | @forge.sort() 658 | def func(c, b, a): 659 | pass 660 | 661 | assert forge.repr_callable(func) == 'func(a, b, c)' 662 | 663 | :param sortkey: a function provided to the builtin :func:`sorted`. 664 | Receives instances of :class:`~forge.FParameter`, and should return a 665 | key to sort on. 666 | """ 667 | @staticmethod 668 | def _sortkey(param): 669 | """ 670 | Default sortkey for :meth:`~forge.sort.revise` that orders by: 671 | 672 | #. :term:`parameter kind`, then 673 | #. parameters having a default value 674 | #. parameter name lexicographically 675 | 676 | :returns: tuple to sort by 677 | """ 678 | return (param.kind, param.default is not empty, param.name or '') 679 | 680 | def __init__( 681 | self, 682 | sortkey: typing.Optional[ 683 | typing.Callable[[FParameter], typing.Any] 684 | ]=None 685 | ) -> None: 686 | self.sortkey = sortkey or self._sortkey 687 | 688 | def revise(self, previous: FSignature) -> FSignature: 689 | """ 690 | Applies the sorting :paramref:`~forge.returns.return_annotation`, to 691 | the input signature. 692 | 693 | No validation is performed on the updated :class:`~forge.FSignature`, 694 | allowing it to be used as an intermediate revision in the context of 695 | :class:`~forge.compose`. 696 | 697 | :param previous: the :class:`~forge.FSignature` to modify 698 | :returns: a modified instance of :class:`~forge.FSignature` 699 | """ 700 | return previous.replace( # type: ignore 701 | parameters=sorted(previous, key=self.sortkey), 702 | __validate_parameters__=False, 703 | ) 704 | 705 | 706 | ## Unit Revisions 707 | class delete(Revision): # pylint: disable=C0103, invalid-name 708 | """ 709 | Revision that deletes one (or more) parameters from an 710 | :class:`~forge.FSignature`. 711 | 712 | :param selector: a string, iterable of strings, or a function that 713 | receives an instance of :class:`~forge.FParameter` and returns a 714 | truthy value whether to exclude it. 715 | :param multiple: whether to delete all parameters that match the 716 | ``selector`` 717 | :param raising: whether to raise an exception if the ``selector`` matches 718 | no parameters 719 | """ 720 | def __init__( 721 | self, 722 | selector: _TYPE_FINDITER_SELECTOR, 723 | multiple: bool = False, 724 | raising: bool = True 725 | ) -> None: 726 | self.selector = selector 727 | self.multiple = multiple 728 | self.raising = raising 729 | 730 | def revise(self, previous: FSignature) -> FSignature: 731 | """ 732 | Deletes one or more parameters from ``previous`` based on instance 733 | attributes. 734 | 735 | No validation is performed on the updated :class:`~forge.FSignature`, 736 | allowing it to be used as an intermediate revision in the context of 737 | :class:`~forge.compose`. 738 | 739 | :param previous: the :class:`~forge.FSignature` to modify 740 | :returns: a modified instance of :class:`~forge.FSignature` 741 | """ 742 | excluded = list(findparam(previous, self.selector)) 743 | if not excluded: 744 | if self.raising: 745 | raise ValueError( 746 | "No parameter matched selector '{}'".format(self.selector) 747 | ) 748 | return previous 749 | 750 | if not self.multiple: 751 | del excluded[1:] 752 | 753 | # https://github.com/python/mypy/issues/5156 754 | return previous.replace( # type: ignore 755 | parameters=[ 756 | param for param in previous 757 | if param not in excluded 758 | ], 759 | __validate_parameters__=False, 760 | ) 761 | 762 | 763 | class insert(Revision): # pylint: disable=C0103, invalid-name 764 | """ 765 | Revision that inserts a new parameter into a signature at an index, 766 | before a selector, or after a selector. 767 | 768 | .. testcode:: 769 | 770 | import forge 771 | 772 | @forge.insert(forge.arg('a'), index=0) 773 | def func(b, **kwargs): 774 | pass 775 | 776 | assert forge.repr_callable(func) == 'func(a, b, **kwargs)' 777 | 778 | :param insertion: the parameter or iterable of parameters to insert 779 | :param index: the index to insert the parameter into the signature 780 | :param before: a string, iterable of strings, or a function that 781 | receives an instance of :class:`~forge.FParameter` and returns a 782 | truthy value whether to place the provided parameter before it. 783 | :param after: a string, iterable of strings, or a function that 784 | receives an instance of :class:`~forge.FParameter` and returns a 785 | truthy value whether to place the provided parameter before it. 786 | """ 787 | def __init__( 788 | self, 789 | insertion: typing.Union[FParameter, typing.Iterable[FParameter]], 790 | *, 791 | index: int = None, 792 | before: _TYPE_FINDITER_SELECTOR = None, 793 | after: _TYPE_FINDITER_SELECTOR = None 794 | ) -> None: 795 | provided = dict(filter( 796 | lambda i: i[1] is not None, 797 | {'index': index, 'before': before, 'after': after}.items(), 798 | )) 799 | if not provided: 800 | raise TypeError( 801 | "expected keyword argument 'index', 'before', or 'after'" 802 | ) 803 | elif len(provided) > 1: 804 | raise TypeError( 805 | "expected 'index', 'before' or 'after' received multiple" 806 | ) 807 | 808 | self.insertion = [insertion] \ 809 | if isinstance(insertion, FParameter) \ 810 | else list(insertion) 811 | self.index = index 812 | self.before = before 813 | self.after = after 814 | 815 | def revise(self, previous: FSignature) -> FSignature: 816 | """ 817 | Inserts the :paramref:`~forge.insert.insertion` into a signature. 818 | 819 | No validation is performed on the updated :class:`~forge.FSignature`, 820 | allowing it to be used as an intermediate revision in the context of 821 | :class:`~forge.compose`. 822 | 823 | :param previous: the :class:`~forge.FSignature` to modify 824 | :returns: a modified instance of :class:`~forge.FSignature` 825 | """ 826 | pparams = list(previous) 827 | nparams = [] 828 | if self.before: 829 | try: 830 | match = next(findparam(pparams, self.before)) 831 | except StopIteration: 832 | raise ValueError( 833 | "No parameter matched selector '{}'".format(self.before) 834 | ) 835 | 836 | for param in pparams: 837 | if param is match: 838 | nparams.extend(self.insertion) 839 | nparams.append(param) 840 | elif self.after: 841 | try: 842 | match = next(findparam(pparams, self.after)) 843 | except StopIteration: 844 | raise ValueError( 845 | "No parameter matched selector '{}'".format(self.after) 846 | ) 847 | 848 | for param in previous: 849 | nparams.append(param) 850 | if param is match: 851 | nparams.extend(self.insertion) 852 | else: 853 | nparams = pparams[:self.index] + \ 854 | self.insertion + \ 855 | pparams[self.index:] 856 | 857 | # https://github.com/python/mypy/issues/5156 858 | return previous.replace( # type: ignore 859 | parameters=nparams, 860 | __validate_parameters__=False, 861 | ) 862 | 863 | 864 | class modify(Revision): # pylint: disable=C0103, invalid-name 865 | """ 866 | Revision that modifies one or more parameters. 867 | 868 | .. testcode:: 869 | 870 | import forge 871 | 872 | @forge.modify('a', kind=forge.FParameter.POSITIONAL_ONLY) 873 | def func(a): 874 | pass 875 | 876 | assert forge.repr_callable(func) == 'func(a, /)' 877 | 878 | :param selector: a string, iterable of strings, or a function that 879 | receives an instance of :class:`~forge.FParameter` and returns a 880 | truthy value whether to place the provided parameter before it. 881 | :param multiple: whether to delete all parameters that match the 882 | ``selector`` 883 | :param raising: whether to raise an exception if the ``selector`` matches 884 | no parameters 885 | :param kind: see :paramref:`~forge.FParameter.kind` 886 | :param name: see :paramref:`~forge.FParameter.name` 887 | :param interface_name: see :paramref:`~forge.FParameter.interface_name` 888 | :param default: see :paramref:`~forge.FParameter.default` 889 | :param factory: see :paramref:`~forge.FParameter.factory` 890 | :param type: see :paramref:`~forge.FParameter.type` 891 | :param converter: see :paramref:`~forge.FParameter.converter` 892 | :param validator: see :paramref:`~forge.FParameter.validator` 893 | :param bound: see :paramref:`~forge.FParameter.bound` 894 | :param contextual: see :paramref:`~forge.FParameter.contextual` 895 | :param metadata: see :paramref:`~forge.FParameter.metadata` 896 | """ 897 | def __init__( 898 | self, 899 | selector: _TYPE_FINDITER_SELECTOR, 900 | multiple: bool = False, 901 | raising: bool = True, 902 | *, 903 | kind=_void, 904 | name=_void, 905 | interface_name=_void, 906 | default=_void, 907 | factory=_void, 908 | type=_void, 909 | converter=_void, 910 | validator=_void, 911 | bound=_void, 912 | contextual=_void, 913 | metadata=_void 914 | ) -> None: 915 | # pylint: disable=W0622, redefined-builtin 916 | # pylint: disable=R0914, too-many-locals 917 | self.selector = selector 918 | self.multiple = multiple 919 | self.raising = raising 920 | self.updates = { 921 | k: v for k, v in { 922 | 'kind': kind, 923 | 'name': name, 924 | 'interface_name': interface_name, 925 | 'default': default, 926 | 'factory': factory, 927 | 'type': type, 928 | 'converter': converter, 929 | 'validator': validator, 930 | 'bound': bound, 931 | 'contextual': contextual, 932 | 'metadata': metadata, 933 | }.items() if v is not _void 934 | } 935 | 936 | def revise(self, previous: FSignature) -> FSignature: 937 | """ 938 | Revises one or more parameters that matches 939 | :paramref:`~forge.modify.selector`. 940 | 941 | No validation is performed on the updated :class:`~forge.FSignature`, 942 | allowing it to be used as an intermediate revision in the context of 943 | :class:`~forge.compose`. 944 | 945 | :param previous: the :class:`~forge.FSignature` to modify 946 | :returns: a modified instance of :class:`~forge.FSignature` 947 | """ 948 | matched = list(findparam(previous, self.selector)) 949 | if not matched: 950 | if self.raising: 951 | raise ValueError( 952 | "No parameter matched selector '{}'".format(self.selector) 953 | ) 954 | return previous 955 | 956 | if not self.multiple: 957 | del matched[1:] 958 | 959 | # https://github.com/python/mypy/issues/5156 960 | return previous.replace( # type: ignore 961 | parameters=[ 962 | param.replace(**self.updates) if param in matched else param 963 | for param in previous 964 | ], 965 | __validate_parameters__=False, 966 | ) 967 | 968 | 969 | class replace(Revision): # pylint: disable=C0103, invalid-name 970 | """ 971 | Revision that replaces a parameter. 972 | 973 | .. testcode:: 974 | 975 | import forge 976 | 977 | @forge.replace('a', forge.kwo('b', 'a')) 978 | def func(a): 979 | pass 980 | 981 | assert forge.repr_callable(func) == 'func(*, b)' 982 | 983 | :param selector: a string, iterable of strings, or a function that 984 | receives an instance of :class:`~forge.FParameter` and returns a 985 | truthy value whether to place the provided parameter before it. 986 | :param parameter: an instance of :class:`~forge.FParameter` to replace 987 | the selected parameter with. 988 | """ 989 | def __init__( 990 | self, 991 | selector: _TYPE_FINDITER_SELECTOR, 992 | parameter: FParameter 993 | ) -> None: 994 | self.selector = selector 995 | self.parameter = parameter 996 | 997 | def revise(self, previous: FSignature) -> FSignature: 998 | """ 999 | Replaces a parameter that matches 1000 | :paramref:`~forge.replace.selector`. 1001 | 1002 | No validation is performed on the updated :class:`~forge.FSignature`, 1003 | allowing it to be used as an intermediate revision in the context of 1004 | :class:`~forge.compose`. 1005 | 1006 | :param previous: the :class:`~forge.FSignature` to modify 1007 | :returns: a modified instance of :class:`~forge.FSignature` 1008 | """ 1009 | try: 1010 | match = next(findparam(previous, self.selector)) 1011 | except StopIteration: 1012 | raise ValueError( 1013 | "No parameter matched selector '{}'".format(self.selector) 1014 | ) 1015 | 1016 | # https://github.com/python/mypy/issues/5156 1017 | return previous.replace( # type: ignore 1018 | parameters=[ 1019 | self.parameter if param is match else param 1020 | for param in previous 1021 | ], 1022 | __validate_parameters__=False, 1023 | ) 1024 | 1025 | 1026 | class translocate(Revision): # pylint: disable=C0103, invalid-name 1027 | """ 1028 | Revision that translocates (moves) a parameter to a new position in a 1029 | signature. 1030 | 1031 | .. testcode:: 1032 | 1033 | import forge 1034 | 1035 | @forge.translocate('a', index=1) 1036 | def func(a, b): 1037 | pass 1038 | 1039 | assert forge.repr_callable(func) == 'func(b, a)' 1040 | 1041 | :param selector: a string, iterable of strings, or a function that 1042 | receives an instance of :class:`~forge.FParameter` and returns a 1043 | truthy value whether to place the provided parameter before it. 1044 | :param index: the index to insert the parameter into the signature 1045 | :param before: a string, iterable of strings, or a function that 1046 | receives an instance of :class:`~forge.FParameter` and returns a 1047 | truthy value whether to place the provided parameter before it. 1048 | :param after: a string, iterable of strings, or a function that 1049 | receives an instance of :class:`~forge.FParameter` and returns a 1050 | truthy value whether to place the provided parameter before it. 1051 | """ 1052 | def __init__(self, selector, *, index=None, before=None, after=None): 1053 | provided = dict(filter( 1054 | lambda i: i[1] is not None, 1055 | {'index': index, 'before': before, 'after': after}.items(), 1056 | )) 1057 | if not provided: 1058 | raise TypeError( 1059 | "expected keyword argument 'index', 'before', or 'after'" 1060 | ) 1061 | elif len(provided) > 1: 1062 | raise TypeError( 1063 | "expected 'index', 'before' or 'after' received multiple" 1064 | ) 1065 | 1066 | self.selector = selector 1067 | self.index = index 1068 | self.before = before 1069 | self.after = after 1070 | 1071 | def revise(self, previous: FSignature) -> FSignature: 1072 | """ 1073 | Translocates (moves) the :paramref:`~forge.insert.parameter` into a 1074 | new position in the signature. 1075 | 1076 | No validation is performed on the updated :class:`~forge.FSignature`, 1077 | allowing it to be used as an intermediate revision in the context of 1078 | :class:`~forge.compose`. 1079 | 1080 | :param previous: the :class:`~forge.FSignature` to modify 1081 | :returns: a modified instance of :class:`~forge.FSignature` 1082 | """ 1083 | try: 1084 | selected = next(findparam(previous, self.selector)) 1085 | except StopIteration: 1086 | raise ValueError( 1087 | "No parameter matched selector '{}'".format(self.selector) 1088 | ) 1089 | 1090 | if self.before: 1091 | try: 1092 | before = next(findparam(previous, self.before)) 1093 | except StopIteration: 1094 | raise ValueError( 1095 | "No parameter matched selector '{}'".format(self.before) 1096 | ) 1097 | 1098 | parameters = [] 1099 | for param in previous: 1100 | if param is before: 1101 | parameters.append(selected) 1102 | elif param is selected: 1103 | continue 1104 | parameters.append(param) 1105 | elif self.after: 1106 | try: 1107 | after = next(findparam(previous, self.after)) 1108 | except StopIteration: 1109 | raise ValueError( 1110 | "No parameter matched selector '{}'".format(self.after) 1111 | ) 1112 | 1113 | parameters = [] 1114 | for param in previous: 1115 | if param is not selected: 1116 | parameters.append(param) 1117 | if param is after: 1118 | parameters.append(selected) 1119 | else: 1120 | parameters = [ 1121 | param for param in previous 1122 | if param is not selected 1123 | ] 1124 | parameters.insert(self.index, selected) 1125 | 1126 | # https://github.com/python/mypy/issues/5156 1127 | return previous.replace( # type: ignore 1128 | parameters=parameters, 1129 | __validate_parameters__=False, 1130 | ) 1131 | 1132 | 1133 | # Convenience name 1134 | move = translocate # pylint: disable=C0103, invalid-name 1135 | -------------------------------------------------------------------------------- /forge/_utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import types 3 | import typing 4 | 5 | import forge._immutable as immutable 6 | from forge._marker import empty 7 | 8 | 9 | class CallArguments(immutable.Immutable): 10 | """ 11 | An immutable container for call arguments, i.e. term:`var-positional` 12 | (e.g. ``*args``) and :term:`var-keyword` (e.g. ``**kwargs``). 13 | 14 | :param args: positional arguments used in a call 15 | :param kwargs: keyword arguments used in a call 16 | """ 17 | __slots__ = ('args', 'kwargs') 18 | 19 | def __init__( 20 | self, 21 | *args: typing.Any, 22 | **kwargs: typing.Any 23 | ) -> None: 24 | super().__init__(args=args, kwargs=types.MappingProxyType(kwargs)) 25 | 26 | def __repr__(self) -> str: 27 | arguments = ', '.join([ 28 | *[repr(arg) for arg in self.args], 29 | *['{}={}'.format(k, v) for k, v in self.kwargs.items()], 30 | ]) 31 | return '<{} ({})>'.format(type(self).__name__, arguments) 32 | 33 | @classmethod 34 | def from_bound_arguments( 35 | cls, 36 | bound: inspect.BoundArguments, 37 | ) -> 'CallArguments': 38 | """ 39 | A factory method that creates an instance of 40 | :class:`~forge._signature.CallArguments` from an instance of 41 | :class:`instance.BoundArguments` generated from 42 | :meth:`inspect.Signature.bind` or :meth:`inspect.Signature.bind_partial` 43 | 44 | :param bound: an instance of :class:`inspect.BoundArguments` 45 | :returns: an unpacked version of :class:`inspect.BoundArguments` 46 | """ 47 | return cls(*bound.args, **bound.kwargs) # type: ignore 48 | 49 | def to_bound_arguments( 50 | self, 51 | signature: inspect.Signature, 52 | partial: bool = False, 53 | ) -> inspect.BoundArguments: 54 | """ 55 | Generates an instance of :class:inspect.BoundArguments` for a given 56 | :class:`inspect.Signature`. 57 | Does not raise if invalid or incomplete arguments are provided, as the 58 | underlying implementation uses :meth:`inspect.Signature.bind_partial`. 59 | 60 | :param signature: an instance of :class:`inspect.Signature` to which 61 | :paramref:`.CallArguments.args` and 62 | :paramref:`.CallArguments.kwargs` will be bound. 63 | :param partial: does not raise if invalid or incomplete arguments are 64 | provided, as the underlying implementation uses 65 | :meth:`inspect.Signature.bind_partial` 66 | :returns: an instance of :class:`inspect.BoundArguments` to which 67 | :paramref:`.CallArguments.args` and 68 | :paramref:`.CallArguments.kwargs` are bound. 69 | """ 70 | return signature.bind_partial(*self.args, **self.kwargs) \ 71 | if partial \ 72 | else signature.bind(*self.args, **self.kwargs) 73 | 74 | 75 | def sort_arguments( 76 | to_: typing.Union[typing.Callable[..., typing.Any], inspect.Signature], 77 | named: typing.Optional[typing.Dict[str, typing.Any]] = None, 78 | unnamed: typing.Optional[typing.Iterable] = None, 79 | ) -> CallArguments: 80 | """ 81 | Iterates over the :paramref:`~forge.sort_arguments.named` arguments and 82 | assinging the values to the parameters with the key as a name. 83 | :paramref:`~forge.sort_arguments.unnamed` arguments are assigned to the 84 | :term:`var-positional` parameter. 85 | 86 | Usage: 87 | 88 | .. testcode:: 89 | 90 | import forge 91 | 92 | def func(a, b=2, *args, c, d=5, **kwargs): 93 | return (a, b, args, c, d, kwargs) 94 | 95 | assert forge.callwith( 96 | func, 97 | named=dict(a=1, c=4, e=6), 98 | unnamed=(3,), 99 | ) == forge.CallArguments(1, 2, 3, c=4, d=5, e=6) 100 | 101 | :param to_: a callable to call with the named and unnamed parameters 102 | :param named: a mapping of parameter names to argument values. 103 | Appropriate values are all :term:`positional-only`, 104 | :term:`positional-or-keyword`, and :term:`keyword-only` arguments, 105 | as well as additional :term:`var-keyword` mapped arguments which will 106 | be used to construct the :term:`var-positional` argument on 107 | :paramref:`~forge.callwith.to_` (if it has such an argument). 108 | Parameters on :paramref:`~forge.callwith.to_` with default values can 109 | be ommitted (as expected). 110 | :param unnamed: an iterable to be passed as the :term:`var-positional` 111 | parameter. Requires :paramref:`~forge.callwith.to_` to accept 112 | :term:`var-positional` arguments. 113 | """ 114 | if not isinstance(to_, inspect.Signature): 115 | to_ = inspect.signature(to_) 116 | to_ba = to_.bind_partial() 117 | to_ba.apply_defaults() 118 | 119 | arguments = named.copy() if named else {} 120 | vpo_param, vkw_param = None, None 121 | 122 | for name, param in to_.parameters.items(): 123 | if param.kind is inspect.Parameter.VAR_POSITIONAL: 124 | vpo_param = param 125 | elif param.kind is inspect.Parameter.VAR_KEYWORD: 126 | vkw_param = param 127 | elif name in arguments: 128 | to_ba.arguments[name] = arguments.pop(name) 129 | continue 130 | elif param.default is empty.native: 131 | raise ValueError( 132 | "Non-default parameter '{}' has no argument value".format(name) 133 | ) 134 | 135 | if arguments: 136 | if not vkw_param: 137 | raise TypeError('Cannot sort arguments ({})'.\ 138 | format(', '.join(arguments.keys()))) 139 | to_ba.arguments[vkw_param.name].update(arguments) 140 | 141 | if unnamed: 142 | if not vpo_param: 143 | raise TypeError("Cannot sort var-positional arguments") 144 | to_ba.arguments[vpo_param.name] = tuple(unnamed) 145 | 146 | return CallArguments.from_bound_arguments(to_ba) 147 | 148 | 149 | def callwith( 150 | to_: typing.Callable[..., typing.Any], 151 | named: typing.Optional[typing.Dict[str, typing.Any]] = None, 152 | unnamed: typing.Optional[typing.Iterable] = None, 153 | ) -> typing.Any: 154 | """ 155 | Calls and returns the result of :paramref:`~forge.callwith.to_` with the 156 | supplied ``named`` and ``unnamed`` arguments. 157 | 158 | The arguments and their order as supplied to 159 | :paramref:`~forge.callwith.to_` is determined by 160 | iterating over the :paramref:`~forge.callwith.named` arguments and 161 | assinging the values to the parameters with the key as a name. 162 | :paramref:`~forge.callwith.unnamed` arguments are assigned to the 163 | :term:`var-positional` parameter. 164 | 165 | Usage: 166 | 167 | .. testcode:: 168 | 169 | import forge 170 | 171 | def func(a, b=2, *args, c, d=5, **kwargs): 172 | return (a, b, args, c, d, kwargs) 173 | 174 | assert forge.callwith( 175 | func, 176 | named=dict(a=1, c=4, e=6), 177 | unnamed=(3,), 178 | ) == (1, 2, (3,), 4, 5, {'e': 6}) 179 | 180 | :param to_: a callable to call with the named and unnamed parameters 181 | :param named: a mapping of parameter names to argument values. 182 | Appropriate values are all :term:`positional-only`, 183 | :term:`positional-or-keyword`, and :term:`keyword-only` arguments, 184 | as well as additional :term:`var-keyword` mapped arguments which will 185 | be used to construct the :term:`var-positional` argument on 186 | :paramref:`~forge.callwith.to_` (if it has such an argument). 187 | Parameters on :paramref:`~forge.callwith.to_` with default values can 188 | be ommitted (as expected). 189 | :param unnamed: an iterable to be passed as the :term:`var-positional` 190 | parameter. Requires :paramref:`~forge.callwith.to_` to accept 191 | :term:`var-positional` arguments. 192 | """ 193 | call_args = sort_arguments(to_, named, unnamed) 194 | return to_(*call_args.args, **call_args.kwargs) 195 | 196 | 197 | def repr_callable(callable: typing.Callable) -> str: 198 | """ 199 | Build a string representation of a callable, including the callable's 200 | :attr:``__name__``, its :class:`inspect.Parameter`s and its ``return type`` 201 | 202 | usage:: 203 | 204 | >>> repr_callable(repr_callable) 205 | 'repr_callable(callable: Callable) -> str' 206 | 207 | :param callable: a Python callable to build a string representation of 208 | :returns: the string representation of the function 209 | """ 210 | # pylint: disable=W0622, redefined-builtin 211 | sig = inspect.signature(callable) 212 | name = getattr(callable, '__name__', str(callable)) 213 | return '{}{}'.format(name, sig) 214 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = python-forge 3 | version = 18.6.0 4 | long_description = file: README.rst 5 | author = Devin Fee 6 | author_email = devin@devinfee.com 7 | url = http://github.com/dfee/forge 8 | description = forge (python signatures) 9 | license = MIT 10 | keywords = 11 | signatures 12 | parameters 13 | arguments 14 | classifiers= 15 | Development Status :: 5 - Production/Stable 16 | Intended Audience :: Developers 17 | License :: OSI Approved :: MIT License 18 | Topic :: Software Development :: Libraries :: Application Frameworks 19 | Programming Language :: Python 20 | Programming Language :: Python :: 3 :: Only 21 | Programming Language :: Python :: 3.5 22 | Programming Language :: Python :: 3.6 23 | Programming Language :: Python :: 3.7 24 | Programming Language :: Python :: Implementation :: CPython 25 | Programming Language :: Python :: Implementation :: PyPy 26 | 27 | [options] 28 | packages = forge 29 | 30 | [options.extras_require] 31 | dev = 32 | coverage 33 | mypy 34 | pylint 35 | pytest 36 | sphinx 37 | sphinx-autodoc-typehints 38 | sphinx_paramlinks 39 | docs = 40 | sphinx >= 1.7.4 41 | docutils 42 | requests 43 | sphinx_paramlinks 44 | testing = 45 | coverage 46 | mypy 47 | pylint 48 | pytest 49 | 50 | [bdist_wheel] 51 | python-tag = py35 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dfee/forge/c9d13f186a9bd240c47e76cda1522c440b799660/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | 5 | import forge 6 | 7 | 8 | @pytest.fixture 9 | def loop(): 10 | # pylint: disable=W0621, redefined-outer-name 11 | loop = asyncio.new_event_loop() 12 | asyncio.set_event_loop(loop) 13 | yield loop 14 | loop.close() 15 | 16 | 17 | @pytest.fixture 18 | def reset_run_validators(): 19 | """ 20 | Helper fixture that resets the state of the ``run_validators`` to its value 21 | before the test was run. 22 | """ 23 | # pylint: disable=W0212, protected-access 24 | prerun = forge._config._run_validators 25 | yield 26 | forge._config._run_validators = prerun 27 | -------------------------------------------------------------------------------- /tests/test__module__.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import forge 4 | 5 | # pylint: disable=C0103, invalid-name 6 | # pylint: disable=R0201, no-self-use 7 | 8 | 9 | def test_namespace(): 10 | """ 11 | Keep the namespace clean 12 | """ 13 | private_ptn = re.compile(r'^\_[a-zA-Z]') 14 | assert set(filter(private_ptn.match, forge.__dict__.keys())) == set([ 15 | '_config', 16 | '_counter', 17 | '_exceptions', 18 | '_immutable', 19 | '_marker', 20 | '_revision', 21 | '_signature', 22 | '_utils', 23 | ]) 24 | 25 | public_ptn = re.compile(r'^[a-zA-Z]') 26 | assert set(filter(public_ptn.match, forge.__dict__.keys())) == set([ 27 | ## Config 28 | 'get_run_validators', 29 | 'set_run_validators', 30 | 31 | ## Revision 32 | 'Revision', 33 | # unit 34 | 'delete', 'insert', 'modify', 'translocate', 'move', 'replace', 35 | # group 36 | 'copy', 37 | 'manage', 38 | 'synthesize', 'sign', 39 | 'sort', 40 | # other 41 | 'compose', 42 | 'returns', 43 | 44 | ## Signature 45 | 'FSignature', 46 | 'Mapper', 47 | 'fsignature', 48 | 'Factory', 49 | 'FParameter', 50 | 'findparam', 51 | # constructors 52 | 'pos', 'pok', 'arg', 'kwo', 'kwarg', 'vkw', 'vpo', 53 | # context 54 | 'ctx', 'self', 'cls', 55 | # variadic 56 | 'args', 'kwargs', 57 | 58 | ## Exceptions 59 | 'ForgeError', 60 | 'ImmutableInstanceError', 61 | 62 | ## Markers 63 | 'empty', 64 | 'void', 65 | 66 | ## Utils 67 | 'callwith', 68 | 'repr_callable', 69 | ]) 70 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | 5 | import forge._config 6 | from forge._config import get_run_validators, set_run_validators 7 | 8 | # pylint: disable=C0103, invalid-name 9 | # pylint: disable=R0201, no-self-use 10 | 11 | 12 | @pytest.mark.usefixtures('reset_run_validators') 13 | class TestRunValidators: 14 | def test_get_run_validators(self): 15 | """ 16 | Ensure ``get_run_validators`` is global. 17 | """ 18 | rvmock = Mock() 19 | forge._config._run_validators = rvmock 20 | assert get_run_validators() == rvmock 21 | 22 | @pytest.mark.parametrize(('val',), [(True,), (False,)]) 23 | def test_set_run_validators(self, val): 24 | """ 25 | Ensure ``set_run_validators`` is global. 26 | """ 27 | forge._config._run_validators = not val 28 | set_run_validators(val) 29 | assert forge._config._run_validators == val 30 | 31 | def test_set_run_validators_bad_param_raises(self): 32 | """ 33 | Ensure calling ``set_run_validators`` with a non-boolean raises. 34 | """ 35 | with pytest.raises(TypeError) as excinfo: 36 | set_run_validators(Mock()) 37 | assert excinfo.value.args[0] == "'run' must be bool." 38 | -------------------------------------------------------------------------------- /tests/test_counter.py: -------------------------------------------------------------------------------- 1 | from forge._counter import Counter, CreationOrderMeta 2 | 3 | 4 | def test_counter(): 5 | """ 6 | Ensure that calling a ``Counter`` instance returns the next value. 7 | """ 8 | counter = Counter() 9 | assert counter() == 0 10 | assert counter() == 1 11 | 12 | 13 | def test_cretion_order_meta(): 14 | """ 15 | Ensure that ``CreationOrderMeta`` classes have instances that are ordered. 16 | """ 17 | class Klass(metaclass=CreationOrderMeta): 18 | pass 19 | 20 | assert hasattr(Klass, '_creation_counter') 21 | # pylint: disable=E1101, no-member 22 | assert isinstance(Klass._creation_counter, Counter) 23 | # pylint: enable=E1101, no-member 24 | klass1, klass2 = Klass(), Klass() 25 | for i, kls in enumerate([klass1, klass2]): 26 | assert hasattr(kls, '_creation_order') 27 | assert i == kls._creation_order 28 | -------------------------------------------------------------------------------- /tests/test_immutable.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from forge._exceptions import ImmutableInstanceError 4 | from forge._immutable import Immutable, asdict, replace 5 | 6 | # pylint: disable=C0103, invalid-name 7 | # pylint: disable=R0201, no-self-use 8 | 9 | 10 | class TestAsDict: 11 | def test__slots__(self): 12 | """ 13 | Ensure that ``asdict`` pulls ivars from classes with ``__slots__``, 14 | and excludes private attributes (those starting with `_`) 15 | """ 16 | class Klass: 17 | __slots__ = ('value', '_priv') 18 | def __init__(self, value): 19 | self.value = value 20 | self._priv = 1 21 | 22 | kwargs = {'value': 1} 23 | ins = Klass(**kwargs) 24 | assert not hasattr(ins, '__dict__') 25 | assert asdict(ins) == kwargs 26 | 27 | def test__dict__(self): 28 | """ 29 | Ensure that ``asdict`` pulls ivars from classes with ``__dict__``, and 30 | excludes private attributes (those starting with `_`) 31 | """ 32 | class Klass: 33 | def __init__(self, value): 34 | self.value = value 35 | self._priv = 1 36 | 37 | kwargs = {'value': 1} 38 | ins = Klass(**kwargs) 39 | assert not hasattr(ins, '__slots__') 40 | assert asdict(ins) == kwargs 41 | 42 | 43 | def test_replace(): 44 | """ 45 | Ensure that ``replace`` produces a varied copy 46 | """ 47 | class Klass: 48 | def __init__(self, value): 49 | self.value = value 50 | 51 | k1 = Klass(1) 52 | k2 = replace(k1, value=2) 53 | assert (k1.value, k2.value) == (1, 2) 54 | 55 | 56 | class TestImmutable: 57 | def test_type(self): 58 | """ 59 | Ensure an instance of ``Immutable`` has ```__slots__` but not 60 | ``__dict__``; i.e. it's truly a slots instance. 61 | """ 62 | ins = Immutable() 63 | assert hasattr(ins, '__slots__') 64 | assert not hasattr(ins, '__dict__') 65 | 66 | def test__init__(self): 67 | """ 68 | Ensure that Immutable.__init__ sets values without relying on 69 | ``__setattr__``. 70 | """ 71 | class Klass(Immutable): 72 | __slots__ = ('a', 'b', 'c') 73 | def __init__(self): 74 | super().__init__(**dict(zip(['a', 'b', 'c'], range(3)))) 75 | 76 | ins = Klass() 77 | for i, key in enumerate(Klass.__slots__): 78 | assert getattr(ins, key) == i 79 | 80 | @pytest.mark.parametrize(('val1', 'val2', 'eq'), [ 81 | pytest.param(1, 1, True, id='eq'), 82 | pytest.param(1, 2, False, id='ne'), 83 | ]) 84 | def test__eq__(self, val1, val2, eq): 85 | """ 86 | Ensure equality check compares ivars. 87 | """ 88 | class Klass(Immutable): 89 | __slots__ = ('a',) 90 | def __init__(self, a): 91 | super().__init__(a=a) 92 | 93 | assert (Klass(val1) == Klass(val2)) == eq 94 | 95 | def test__eq__type(self): 96 | """ 97 | Ensure equality check compares types 98 | """ 99 | class Klass1(Immutable): 100 | __slots__ = ('a',) 101 | def __init__(self, a): 102 | super().__init__(a=a) 103 | 104 | class Klass2: 105 | __slots__ = ('a',) 106 | def __init__(self, a): 107 | self.a = a 108 | 109 | assert Klass1(1) != Klass2(1) 110 | 111 | def test__getattr__(self): 112 | """ 113 | Ensure ``__getattr__`` passes request to ``super().__getattribute__`` 114 | """ 115 | class Parent: 116 | called_with = None 117 | def __getattribute__(self, key): 118 | type(self).called_with = key 119 | 120 | class Klass(Immutable, Parent): 121 | pass 122 | 123 | ins = Klass() 124 | assert not ins.default 125 | assert Klass.called_with == 'default' 126 | 127 | def test__setattr__(self): 128 | """ 129 | Ensure Immutable is immutable; ``__setattr__`` raises 130 | """ 131 | class Klass(Immutable): 132 | a = 1 133 | 134 | ins = Klass() 135 | with pytest.raises(ImmutableInstanceError) as excinfo: 136 | ins.a = 1 137 | assert excinfo.value.args[0] == "cannot assign to field 'a'" 138 | -------------------------------------------------------------------------------- /tests/test_marker.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | import pytest 4 | 5 | from forge._marker import MarkerMeta, empty, void 6 | 7 | # pylint: disable=R0201, no-self-use 8 | # pylint: disable=W0212, protected-access 9 | 10 | 11 | class TestMarkerMeta: 12 | @pytest.fixture 13 | def make_marker(self): 14 | """ 15 | Helper fixture that returns a factory for creating new markers. 16 | """ 17 | return lambda name: MarkerMeta(name, (), {}) 18 | 19 | def test__repr__(self, make_marker): 20 | """ 21 | Ensure that ``__repr__`` is provided for subclasses (i.e. not instances) 22 | """ 23 | name = 'dummy' 24 | assert repr(make_marker(name)) == '<{}>'.format(name) 25 | 26 | def test_ins__repr__(self, make_marker): 27 | """ 28 | Ensure that calling ``__new__`` simply returns the cls. 29 | """ 30 | marker = make_marker('dummy') 31 | assert repr(marker()) == repr(marker) 32 | 33 | 34 | class TestEmpty: 35 | def test_cls(self): 36 | """ 37 | Ensure cls is an instance of MarkerMeta, and retains the native empty. 38 | """ 39 | assert isinstance(empty, MarkerMeta) 40 | assert empty.native is inspect.Parameter.empty 41 | 42 | @pytest.mark.parametrize(('in_', 'out_'), [ 43 | pytest.param(1, 1, id='non_empty'), 44 | pytest.param(empty, inspect.Parameter.empty, id='empty'), 45 | ]) 46 | def test_ccoerce_native(self, in_, out_): 47 | """ 48 | Ensure that conditional coercion to ``inspect.Parameter.empty`` 49 | works correctly. 50 | """ 51 | assert empty.ccoerce_native(in_) == out_ 52 | 53 | @pytest.mark.parametrize(('in_', 'out_'), [ 54 | pytest.param(1, 1, id='non_empty'), 55 | pytest.param(inspect.Parameter.empty, empty, id='empty'), 56 | ]) 57 | def test_ccoerce_synthetic(self, in_, out_): 58 | """ 59 | Ensure that conditional coercion to the ``forge.empty`` works correctly. 60 | """ 61 | assert empty.ccoerce_synthetic(in_) == out_ 62 | 63 | 64 | 65 | class TestVoid: 66 | def test_cls(self): 67 | """ 68 | Ensure cls is an instance of MarkerMeta. 69 | """ 70 | assert isinstance(void, MarkerMeta) 71 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import sys 3 | 4 | import pytest 5 | 6 | from forge._signature import ( 7 | KEYWORD_ONLY, 8 | POSITIONAL_ONLY, 9 | POSITIONAL_OR_KEYWORD, 10 | VAR_KEYWORD, 11 | VAR_POSITIONAL, 12 | ) 13 | from forge._utils import CallArguments, callwith, repr_callable, sort_arguments 14 | 15 | # pylint: disable=C0103, invalid-name 16 | # pylint: disable=R0201, no-self-use 17 | 18 | 19 | class TestCallArguments: 20 | def test_from_bound_arguments(self): 21 | """ 22 | Ensure that ``inspect.BoundArguments`` ``args`` and ``kwargs`` are 23 | properly mapped to a new ``CallArguments`` instance. 24 | """ 25 | # pylint: disable=W0613, unused-argument 26 | def func(a, *, b): 27 | pass 28 | bound = inspect.signature(func).bind(a=1, b=2) 29 | # pylint: disable=E1101, no-member 30 | assert CallArguments.from_bound_arguments(bound) == \ 31 | CallArguments(1, b=2) 32 | 33 | @pytest.mark.parametrize(('partial',), [(True,), (False,)]) 34 | @pytest.mark.parametrize(('call_args', 'incomplete'), [ 35 | pytest.param(CallArguments(1, b=2), False, id='complete'), 36 | pytest.param(CallArguments(), True, id='incomplete'), 37 | ]) 38 | def test_to_bound_arguments(self, call_args, partial, incomplete): 39 | """ 40 | Ensure that ``CallArguments`` ``args`` and ``kwargs`` are 41 | properly mapped to a new ``inspect.BoundArguments`` instance. 42 | """ 43 | # pylint: disable=W0613, unused-argument 44 | def func(a, *, b): 45 | pass 46 | sig = inspect.signature(func) 47 | if not partial and incomplete: 48 | with pytest.raises(TypeError) as excinfo: 49 | call_args.to_bound_arguments(sig, partial=partial) 50 | assert excinfo.value.args[0] == \ 51 | "missing a required argument: 'a'" 52 | return 53 | assert call_args.to_bound_arguments(sig, partial=partial) == \ 54 | sig.bind_partial(*call_args.args, **call_args.kwargs) 55 | 56 | @pytest.mark.parametrize(('args', 'kwargs', 'expected'), [ 57 | pytest.param((0,), {}, '0', id='args_only'), 58 | pytest.param((), {'a': 1}, 'a=1', id='kwargs_only'), 59 | pytest.param((0,), {'a': 1}, '0, a=1', id='args_and_kwargs'), 60 | pytest.param((), {}, '', id='neither_args_nor_kwargs'), 61 | ]) 62 | def test__repr__(self, args, kwargs, expected): 63 | """ 64 | Ensure that ``CallArguments.__repr__`` is a pretty print of ``args`` 65 | and ``kwargs``. 66 | """ 67 | assert repr(CallArguments(*args, **kwargs)) == \ 68 | ''.format(expected) 69 | 70 | 71 | @pytest.mark.parametrize(('strategy',), [('class_callable',), ('function',)]) 72 | def test_repr_callable(strategy): 73 | """ 74 | Ensure that callables are stringified with: 75 | - func.__name__ OR repr(func) if class callable 76 | - parameters 77 | - return type annotation 78 | """ 79 | # pylint: disable=W0622, redefined-builtin 80 | class Dummy: 81 | def __init__(self, value: int = 0) -> None: 82 | self.value = value 83 | def __call__(self, value: int = 1) -> int: 84 | return value 85 | 86 | if strategy == 'class_callable': 87 | ins = Dummy() 88 | expected = '{}(value:int=1) -> int'.format(ins) \ 89 | if sys.version_info.minor < 7 \ 90 | else '{}(value: int = 1) -> int'.format(ins) 91 | assert repr_callable(ins) == expected 92 | elif strategy == 'function': 93 | expected = 'Dummy(value:int=0) -> None' \ 94 | if sys.version_info.minor < 7 \ 95 | else 'Dummy(value: int = 0) -> None' 96 | assert repr_callable(Dummy) == expected 97 | else: 98 | raise TypeError('Unknown strategy {}'.format(strategy)) 99 | 100 | 101 | class TestSortArguments: 102 | @pytest.mark.parametrize(('kind', 'named', 'unnamed', 'expected'), [ 103 | pytest.param( # func(param, /) 104 | POSITIONAL_ONLY, dict(param=1), None, CallArguments(1), 105 | id='positional-only', 106 | ), 107 | pytest.param( # func(param) 108 | POSITIONAL_OR_KEYWORD, dict(param=1), None, CallArguments(1), 109 | id='positional-or-keyword', 110 | ), 111 | pytest.param( # func(*param) 112 | VAR_POSITIONAL, None, (1,), CallArguments(1), 113 | id='var-positional', 114 | ), 115 | pytest.param( # func(*, param) 116 | KEYWORD_ONLY, dict(param=1), None, CallArguments(param=1), 117 | id='keyword-only', 118 | ), 119 | pytest.param( # func(**param) 120 | VAR_KEYWORD, dict(param=1), None, CallArguments(param=1), 121 | id='var-keyword', 122 | ), 123 | ]) 124 | def test_sorting(self, kind, named, unnamed, expected): 125 | """ 126 | Ensure that a named argument is appropriately sorted into a: 127 | - positional-only param 128 | - positional-or-keyword param 129 | - keyword-only param 130 | - var-keyword param 131 | """ 132 | to_ = inspect.Signature([inspect.Parameter('param', kind)]) 133 | result = sort_arguments(to_, named, unnamed) 134 | assert result == expected 135 | 136 | @pytest.mark.parametrize(('kind', 'expected'), [ 137 | pytest.param( # func(param=1, /) 138 | POSITIONAL_ONLY, CallArguments(1), 139 | id='positional-only', 140 | ), 141 | pytest.param( # func(param=1) 142 | POSITIONAL_OR_KEYWORD, CallArguments(1), 143 | id='positional-or-keyword', 144 | ), 145 | pytest.param( # func(*, param=1) 146 | KEYWORD_ONLY, CallArguments(param=1), 147 | id='keyword-only', 148 | ), 149 | ]) 150 | def test_sorting_with_defaults(self, kind, expected): 151 | """ 152 | Ensure that unsuplied named arguments use default values 153 | """ 154 | to_ = inspect.Signature([inspect.Parameter('param', kind, default=1)]) 155 | result = sort_arguments(to_) 156 | assert result == expected 157 | 158 | @pytest.mark.parametrize(('kind',), [ 159 | pytest.param(POSITIONAL_ONLY, id='positional-only'), 160 | pytest.param(POSITIONAL_OR_KEYWORD, id='positional-or-keyword'), 161 | pytest.param(KEYWORD_ONLY, id='keyword-only'), 162 | ]) 163 | def test_no_argument_for_non_default_param_raises(self, kind): 164 | """ 165 | Ensure that a non-default parameter must have an argument passed 166 | """ 167 | sig = inspect.Signature([inspect.Parameter('a', kind)]) 168 | with pytest.raises(ValueError) as excinfo: 169 | sort_arguments(sig) 170 | assert excinfo.value.args[0] == \ 171 | "Non-default parameter 'a' has no argument value" 172 | 173 | def test_extra_to_sig_without_vko_raises(self): 174 | """ 175 | Ensure a signature without a var-keyword parameter raises when extra 176 | arguments are supplied 177 | """ 178 | sig = inspect.Signature() 179 | with pytest.raises(TypeError) as excinfo: 180 | sort_arguments(sig, {'a': 1}) 181 | assert excinfo.value.args[0] == 'Cannot sort arguments (a)' 182 | 183 | def test_unnamaed_to_sig_without_vpo_raises(self): 184 | """ 185 | Ensure a signature without a var-positional parameter raises when a 186 | var-positional argument is supplied 187 | """ 188 | sig = inspect.Signature() 189 | with pytest.raises(TypeError) as excinfo: 190 | sort_arguments(sig, unnamed=(1,)) 191 | assert excinfo.value.args[0] == 'Cannot sort var-positional arguments' 192 | 193 | def test_callable(self): 194 | """ 195 | Ensure that callable's are viable arguments for ``to_`` 196 | """ 197 | func = lambda a, b=2, *args, c, d=4, **kwargs: None 198 | assert sort_arguments(func, dict(a=1, c=3, e=5), ('args1',)) == \ 199 | CallArguments(1, 2, 'args1', c=3, d=4, e=5) 200 | 201 | 202 | def test_callwith(): 203 | """ 204 | Ensure that ``callwith`` works as expected 205 | """ 206 | func = lambda a, b=2, *args, c, d=4, **kwargs: (a, b, args, c, d, kwargs) 207 | assert callwith(func, dict(a=1, c=3, e=5), ('args1',)) == \ 208 | (1, 2, ('args1',), 3, 4, {'e': 5}) 209 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | omit = tests/ 3 | branch = true 4 | 5 | [coverage:report] 6 | show_missing = true 7 | exclude_lines = 8 | # Have to re-enable the standard pragma 9 | pragma: no cover 10 | # Don't complain if tests don't hit defensive assertion code: 11 | raise NotImplementedError 12 | 13 | [tool:pytest] 14 | testpaths = tests/ 15 | 16 | [tox] 17 | envlist = 18 | py36 19 | py37 20 | pypy 21 | coverage 22 | docs 23 | lint 24 | 25 | [testenv] 26 | basepython = 27 | py3: python3.6 28 | py35: python3.5 29 | py36: python3.6 30 | py37: python3.7 31 | 32 | commands = 33 | pip install -q python-forge[testing] 34 | pytest {posargs:} 35 | 36 | [testenv:coverage] 37 | basepython = python3.6 38 | commands = 39 | pip install -q python-forge[testing] 40 | coverage run --source=forge {envbindir}/pytest {posargs:} 41 | coverage xml 42 | coverage report --show-missing --fail-under=100 43 | setenv = 44 | COVERAGE_FILE=.coverage 45 | 46 | [testenv:docs] 47 | basepython = python3.6 48 | whitelist_externals = make 49 | commands = 50 | pip install python-forge[docs] 51 | make -C docs doctest html epub BUILDDIR={envdir} "SPHINXOPTS=-W -E" 52 | 53 | [testenv:lint] 54 | basepython = python3.6 55 | commands = 56 | pip install -q python-forge[testing] 57 | pylint forge --rcfile=.pylintrc 58 | mypy --follow-imports silent -m forge 59 | setenv = 60 | MYPYPATH={envsitepackagesdir} --------------------------------------------------------------------------------