├── .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}
--------------------------------------------------------------------------------