diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..0d2fbb50 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,26 @@ +--- +name: Bug report +about: Create a report to help us improve + +--- + +#### Description +*A clear and concise description of what the bug is.* + +#### To Reproduce +*Share how the bug happened:* + +##### Model / Factory code +```python +# Include your factories and models here +``` + +##### The issue +*Add a short description along with your code* + +```python +# Include the code that provoked the bug, including as full a stack-trace as possible +``` + +#### Notes +*Add any notes you feel relevant here :)* diff --git a/.github/ISSUE_TEMPLATE/improvement-suggestion.md b/.github/ISSUE_TEMPLATE/improvement-suggestion.md new file mode 100644 index 00000000..cd109f64 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/improvement-suggestion.md @@ -0,0 +1,14 @@ +--- +name: Improvement suggestion +about: Suggest an idea for this project + +--- + +#### The problem +*Please describe the problem you're encountering (e.g "It's very complex to do [...]")* + +#### Proposed solution +*Please provide some wild idea you think could solve this issue. It's much easier to work from an existing suggestion :)* + +#### Extra notes +*Any notes you feel interesting to include: alternatives you've considered, reasons to include the change, anything!* diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 00000000..1336c012 --- /dev/null +++ b/.github/SUPPORT.md @@ -0,0 +1,9 @@ +# Getting support + +Most questions should be asked with the `factory-boy` tag on +[StackOverflow](https://stackoverflow.com/questions/tagged/factory-boy). +Alternatively, a discussion group exists at +https://groups.google.com/d/forum/factoryboy. + +Please **do not open issues for support requests**. Issues are meant for bug +reports and improvement suggestions. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..d4880e03 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + GitHub_Actions: + patterns: + - "*" # Group all Actions updates into a single larger pull request diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 00000000..d2662307 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,42 @@ +name: Check + +on: + push: + branches: + - "master" + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + name: ${{ matrix.tox-environment }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + tox-environment: + - docs + - examples + - lint + + env: + TOXENV: ${{ matrix.tox-environment }} + + steps: + - uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3' + cache: pip + + - name: Install dependencies + run: python -m pip install tox + + - name: Run + run: tox diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml new file mode 100644 index 00000000..a7b91f8b --- /dev/null +++ b/.github/workflows/linkcheck.yml @@ -0,0 +1,23 @@ +name: Linkcheck + +on: + schedule: + - cron: '11 11 * * 1' + +jobs: + build: + name: Linkcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: '3' + + - name: Install dependencies + run: python -m pip install tox + + - name: Run linkcheck + run: tox -e linkcheck diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..fa32c9bb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,43 @@ +name: Test + +on: + push: + branches: + - "master" + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "pypy-3.10" + - "pypy-3.11" + + steps: + - uses: actions/checkout@v5 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: python -m pip install tox-gh-actions + + - name: Run tests + run: tox diff --git a/.gitignore b/.gitignore index b4d25fca..f2ee4207 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,11 @@ .*.swp *.pyc *.pyo +.idea/ # Build-related files docs/_build/ +auto_dev_requirements*.txt .coverage .tox *.egg-info @@ -13,3 +15,4 @@ build/ dist/ htmlcov/ MANIFEST +tags diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index e9f43c99..00000000 --- a/.pylintrc +++ /dev/null @@ -1,240 +0,0 @@ -[MASTER] - -# Specify a configuration file. -#rcfile= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -init-hook='import os, sys; sys.path.append(os.getcwd())' - -# Profiled execution. -profile=no - -# Add to the black list. It should be a base name, not a -# path. You may set this option multiple times. -ignore= - -# Pickle collected data for later comparisons. -persistent=yes - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. -load-plugins= - - -[MESSAGES CONTROL] - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time. -#enable= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). -#disable=C0103,C0111,C0302,E1002,E1101,E1102,E1103,I0011,I0013,R0201,R0801,R0901,R0902,R0903,R0904,R0912,R0914,R0915,R0921,R0923,W0108,W0212,W0232,W0141,W0142,W0401,W0613,R0924 -disable=C0103,C0111,I0011,R0201,R0903,R0922,W0142,W0212,W0232,W0613 -# see http://www.logilab.org/card/pylintfeatures - - -[REPORTS] - -# Set the output format. Available formats are text, parseable, colorized, msvs -# (visual studio) and html -output-format=text - -# Include message's id in output -include-ids=yes - -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". -files-output=no - -# Tells whether to display a full report or only the messages -reports=yes - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Add a comment according to your evaluation note. This is used by the global -# evaluation report (RP0004). -comment=no - - -[BASIC] - -# Required attributes for module, separated by a comma -required-attributes= - -# List of builtins function names that should not be used, separated by a comma -bad-functions=map,filter,apply,input - -# Regular expression which should only match correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Regular expression which should only match correct module level names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Regular expression which should only match correct class names -class-rgx=[A-Z_][a-zA-Z0-9]+$ - -# Regular expression which should only match correct function names -function-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match correct method names -method-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match correct instance attribute names -attr-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match correct argument names -argument-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match correct variable names -variable-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match correct list comprehension / -# generator expression variable names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ - -# Good variable names which should always be accepted, separated by a comma -good-names=i,j,k,ex,Run,_ - -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata - -# Regular expression which should only match functions or classes name which do -# not require a docstring -no-docstring-rgx=__.*__ - - -[FORMAT] - -# Maximum number of characters on a single line. -max-line-length=80 - -# Maximum number of lines in a module -max-module-lines=1200 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME,XXX,TODO - - -[SIMILARITIES] - -# Minimum lines number of a similarity. -min-similarity-lines=4 - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - - -[TYPECHECK] - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# List of classes names for which member attributes should not be checked -# (useful for classes with attributes dynamically set). -ignored-classes=SQLObject - -# When zope mode is activated, add a predefined set of Zope acquired attributes -# to generated-members. -zope=no - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E0201 when accessed. -generated-members=REQUEST,acl_users,aq_parent - - -[VARIABLES] - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# A regular expression matching the beginning of the name of dummy variables -# (i.e. not used). -dummy-variables-rgx=_|dummy - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= - - -[CLASSES] - -# List of interface methods to ignore, separated by a comma. This is used for -# instance to not check methods defines in Zope's Interface base class. -ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp - - -[DESIGN] - -# Maximum number of arguments for function / method -max-args=8 - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.* - -# Maximum number of locals for function / method body -max-locals=15 - -# Maximum number of return / yield for function / method body -max-returns=6 - -# Maximum number of branch for function / method body -max-branchs=12 - -# Maximum number of statements in function / method body -max-statements=50 - -# Maximum number of parents for a class (see R0901). -max-parents=10 - -# Maximum number of attributes for a class (see R0902). -max-attributes=10 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - - -[IMPORTS] - -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=regsub,string,TERMIOS,Bastion,rexec - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..2bba49e3 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +version: 2 +build: + os: "ubuntu-lts-latest" + tools: + python: "latest" + +python: + install: + - method: pip + path: . + extra_requirements: + - doc + +sphinx: + configuration: docs/conf.py + fail_on_warning: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 3edfd111..00000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: python - -python: - - "2.6" - - "2.7" - - "3.2" - - "3.3" - - "pypy" - -script: - - python setup.py test - -install: - - if [[ $TRAVIS_PYTHON_VERSION = 2.6 ]]; then pip install unittest2 --use-mirrors; fi - - pip install Django Pillow sqlalchemy --use-mirrors - -notifications: - email: false - irc: "irc.freenode.org#factory_boy" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..d55199f3 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at raphael DOT barrois AT xelmail DOT com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 00000000..3f2b64d1 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,78 @@ +Contributing +============ + +Thanks for taking the time to contribute to factory_boy! + +Code of Conduct +--------------- + +This project and everyone participating in it is governed by the `Code of +Conduct`_. By participating, you are expected to uphold this code. Please +report inappropriate behavior to raphael DOT barrois AT xelmail DOT com. + +.. _Code of Conduct: https://github.com/FactoryBoy/factory_boy/blob/master/CODE_OF_CONDUCT.md + +*(If I'm the person with the inappropriate behavior, please accept my +apologies. I know I can mess up. I can't expect you to tell me, but if you +chose to do so, I'll do my best to handle criticism constructively. +-- Raphaël)* + +*(As the community around this project grows, we hope to have more core +developers available to handle that kind of issues)* + + +Contributions +------------- + +Bug reports, patches, documentation improvements and suggestions are welcome! + +Please open an issue_ or send a `pull request`_. + +Feedback about the documentation is especially valuable — the authors of +``factory_boy`` feel more confident about writing code than writing docs :-) + +.. _issue: https://github.com/FactoryBoy/factory_boy/issues/new +.. _pull request: https://github.com/FactoryBoy/factory_boy/compare/ + + +Where to start? +--------------- + +If you're new to the project and want to help, a great first step would be: + +* Fixing an issue in the docs (outdated setup instructions, missing information, + unclear feature, etc.); +* Working on an existing issue (some should be marked ``BeginnerFriendly``); +* Reviewing an existing pull request; +* Or any other way you'd like to help. + + +Code contributions +------------------ + +In order to merge some code, you'll need to open a `pull request`_. + +There are a few rules to keep in mind regarding pull requests: + +* A pull request should only solve a single issue / add a single feature; +* If the code change is significant, please also create an issue_ for easier discussion; +* We have automated testing; please make sure that the updated code passes automated checks; +* We're striving to improve the quality of the library, with higher test and docs coverage. + If you don't know how/where to add docs or tests, we'll be very happy to point you in the right + direction! + + +Questions +--------- + +GitHub issues aren't a good medium for handling questions. There are better +places to ask questions, for example Stack Overflow; please use the +``factory-boy`` tag to make those questions easy to find by the maintainers. + +If you want to ask a question anyway, please make sure that: + +- it's a question about ``factory_boy`` and not about ``Django`` or ``Faker``; +- it isn't answered by the documentation; +- it wasn't asked already. + +A good question can be written as a suggestion to improve the documentation. diff --git a/CREDITS b/CREDITS new file mode 100644 index 00000000..3c13c247 --- /dev/null +++ b/CREDITS @@ -0,0 +1,118 @@ +Credits +======= + + +Maintainers +----------- + +The ``factory_boy`` project is operated and maintained by: + +* Jeff Widman (https://github.com/jeffwidman) +* Raphaël Barrois (https://github.com/rbarrois) + + +.. _contributors: + +Contributors +------------ + +The project was initially created by Mark Sandstrom . + + +The project has received contributions from (in alphabetical order): + +* Adam Chainz +* Alejandro +* Alexey Kotlyarov +* Amit Shah +* Anas Zahim (https://github.com/kamotos) +* Andrey Voronov +* Branko Majic +* Carl Meyer +* Chris Lasher +* Chris Seto +* Christoph Sieghart +* David Baumgold +* Demur Nodia (https://github.com/demonno) +* Eduard Iskandarov +* Federico Bond (https://github.com/federicobond) +* Flavio Curella +* François Freitag +* George Hickman +* Grégoire Deveaux +* Hervé Cauwelier +* Hugo Osvaldo Barrera +* Ilya Baryshev +* Ilya Pirogov +* Ionuț Arțăriși +* Issa Jubril +* Ivan Miric +* Janusz Skonieczny +* Javier Buzzi (https://github.com/kingbuzzman) +* Jeff Widman (https://github.com/jeffwidman) +* Jon Dufresne +* Jonathan Tushman +* Joshua Carp +* Leonardo Lazzaro +* Luke GB +* Marc Abramowitz +* Mark Sandstrom +* Martin Bächtold (https://github.com/mbaechtold) +* Michael Joseph +* Mikhail Korobov +* Oleg Pidsadnyi +* Omer +* Pauly Fenwar +* Peter Marsh +* Puneeth Chaganti +* QuantumGhost +* Raphaël Barrois (https://github.com/rbarrois) +* Rich Rauenzahn +* Richard Moch +* Rob Zyskowski +* Robrecht De Rouck +* Samuel Paccoud +* Sarah Boyce +* Saul Shanabrook +* Sean Löfgren +* Shahriar Tajbakhsh +* Tom +* alex-netquity +* anentropic +* minimumserious +* mluszczyk +* nkryptic +* obiwanus +* tsouvarev +* yamaneko + + + +Contributor license agreement +----------------------------- + +.. note:: This agreement is required to allow redistribution of submitted contributions. + See http://oss-watch.ac.uk/resources/cla for an explanation. + +Any contributor proposing updates to the code or documentation of this project *MUST* +add its name to the list in the :ref:`contributors` section, thereby "signing" the +following contributor license agreement: + +They accept and agree to the following terms for their present end future contributions +submitted to the ``factory_boy`` project: + +* They represent that they are legally entitled to grant this license, and that their + contributions are their original creation + +* They grant the ``factory_boy`` project a perpetual, worldwide, non-exclusive, + no-charge, royalty-free, irrevocable copyright license to reproduce, + prepare derivative works of, publicly display, sublicense and distribute their contributions + and such derivative works. + +* They are not expected to provide support for their contributions, except to the extent they + desire to provide support. + + +.. note:: The above agreement is inspired by the Apache Contributor License Agreement. + +.. vim:set ft=rst: diff --git a/LICENSE b/LICENSE index 620dc61f..a9cfab38 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,6 @@ Copyright (c) 2010 Mark Sandstrom -Copyright (c) 2011-2013 Raphaël Barrois +Copyright (c) 2011-2015 Raphaël Barrois +Copyright (c) The FactoryBoy project Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in index 3dfc1bef..bd58e2c1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,13 @@ -include README.rst -include docs/Makefile -recursive-include docs *.py *.rst -include docs/_static/.keep_dir +include ChangeLog CODE_OF_CONDUCT.md CONTRIBUTING.rst CREDITS LICENSE README.rst +include Makefile tox.ini + +graft factory + +graft docs +graft examples +graft tests + +exclude .readthedocs.yaml +global-exclude *.py[cod] __pycache__ .*.sw[po] +prune .github prune docs/_build -recursive-include tests *.py *.data diff --git a/Makefile b/Makefile index bb0428b6..f7404eba 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,15 @@ PACKAGE=factory TESTS_DIR=tests DOC_DIR=docs +EXAMPLES_DIR=examples +SETUP_PY=setup.py # Use current python binary instead of system default. COVERAGE = python $(shell which coverage) +FLAKE8 = flake8 +ISORT = isort +CTAGS = ctags + all: default @@ -11,6 +17,11 @@ all: default default: +# Package management +# ================== + + +# DOC: Remove temporary or compiled files clean: find . -type f -name '*.pyc' -delete find . -type f -path '*/__pycache__/*' -delete @@ -18,20 +29,112 @@ clean: @rm -rf tmp_test/ +# DOC: Install and/or upgrade dependencies +update: + pip install --upgrade pip setuptools + pip install --upgrade --editable .[dev,doc] + pip freeze + + +release: + fullrelease + + +.PHONY: clean update release + + +# Tests and quality +# ================= + + +# DOC: Run tests for all supported versions (creates a set of virtualenvs) +testall: + tox + +# DOC: Run tests for the currently installed version +# Remove cgi warning when dropping support for Django 3.2. test: - python -W default setup.py test + mypy --ignore-missing-imports tests/test_typing.py + python \ + -b \ + -X dev \ + -Werror \ + -Wignore:::mongomock: \ + -Wignore:::mongomock.__version__: \ + -Wignore:::pkg_resources: \ + -m unittest + +# DOC: Test the examples +example-test: + $(MAKE) -C $(EXAMPLES_DIR) test + -pylint: - pylint --rcfile=.pylintrc --report=no $(PACKAGE)/ + +# Note: we run the linter in two runs, because our __init__.py files has specific warnings we want to exclude +# DOC: Perform code quality tasks +lint: + $(FLAKE8) --exclude $(PACKAGE)/__init__.py $(EXAMPLES_DIR) $(PACKAGE) $(SETUP_PY) $(TESTS_DIR) + $(FLAKE8) --ignore F401 $(PACKAGE)/__init__.py + $(ISORT) --check-only --diff $(EXAMPLES_DIR) $(PACKAGE) $(SETUP_PY) $(TESTS_DIR) + check-manifest coverage: $(COVERAGE) erase - $(COVERAGE) run "--include=$(PACKAGE)/*.py,$(TESTS_DIR)/*.py" --branch setup.py test - $(COVERAGE) report "--include=$(PACKAGE)/*.py,$(TESTS_DIR)/*.py" - $(COVERAGE) html "--include=$(PACKAGE)/*.py,$(TESTS_DIR)/*.py" + $(COVERAGE) run --branch -m unittest + $(COVERAGE) report + $(COVERAGE) html + + +.PHONY: test testall example-test lint coverage + + +# Development +# =========== + +# DOC: Generate a "tags" file +TAGS: + $(CTAGS) --recurse $(PACKAGE) $(TESTS_DIR) + +.PHONY: TAGS + +# Documentation +# ============= + + +# DOC: Compile the documentation doc: - $(MAKE) -C $(DOC_DIR) html + $(MAKE) -C $(DOC_DIR) SPHINXOPTS="-n -W" html + +linkcheck: + $(MAKE) -C $(DOC_DIR) linkcheck + +spelling: + $(MAKE) -C $(DOC_DIR) SPHINXOPTS=-W spelling + +# DOC: Show this help message +help: + @grep -A1 '^# DOC:' Makefile \ + | awk ' \ + BEGIN { FS="\n"; RS="--\n"; opt_len=0; } \ + { \ + doc=$$1; name=$$2; \ + sub("# DOC: ", "", doc); \ + sub(":", "", name); \ + if (length(name) > opt_len) { \ + opt_len = length(name) \ + } \ + opts[NR] = name; \ + docs[name] = doc; \ + } \ + END { \ + pat="%-" (opt_len + 4) "s %s\n"; \ + asort(opts); \ + for (i in opts) { \ + opt=opts[i]; \ + printf pat, opt, docs[opt] \ + } \ + }' -.PHONY: all default clean coverage doc pylint test +.PHONY: doc linkcheck help diff --git a/README.rst b/README.rst index 0371b28a..cd926690 100644 --- a/README.rst +++ b/README.rst @@ -1,44 +1,103 @@ factory_boy =========== -.. image:: https://secure.travis-ci.org/rbarrois/factory_boy.png?branch=master - :target: http://travis-ci.org/rbarrois/factory_boy/ +.. image:: https://github.com/FactoryBoy/factory_boy/workflows/Test/badge.svg + :target: https://github.com/FactoryBoy/factory_boy/actions?query=workflow%3ATest -factory_boy is a fixtures replacement based on thoughtbot's `factory_girl `_. +.. image:: https://github.com/FactoryBoy/factory_boy/workflows/Check/badge.svg + :target: https://github.com/FactoryBoy/factory_boy/actions?query=workflow%3ACheck -Its features include: +.. image:: https://img.shields.io/pypi/v/factory_boy.svg + :target: https://factoryboy.readthedocs.io/en/latest/changelog.html + :alt: Latest Version -- Straightforward syntax -- Support for multiple build strategies (saved/unsaved instances, attribute dicts, stubbed objects) -- Powerful helpers for common cases (sequences, sub-factories, reverse dependencies, circular factories, ...) +.. image:: https://img.shields.io/pypi/pyversions/factory_boy.svg + :target: https://pypi.org/project/factory-boy/ + :alt: Supported Python versions + +.. image:: https://img.shields.io/pypi/wheel/factory_boy.svg + :target: https://pypi.org/project/factory-boy/ + :alt: Wheel status + +.. image:: https://img.shields.io/pypi/l/factory_boy.svg + :target: https://github.com/FactoryBoy/factory_boy/blob/master/LICENSE + :alt: License + +factory_boy is a fixtures replacement based on thoughtbot's `factory_bot `_. + +As a fixtures replacement tool, it aims to replace static, hard to maintain fixtures +with easy-to-use factories for complex objects. + +Instead of building an exhaustive test setup with every possible combination of corner cases, +``factory_boy`` allows you to use objects customized for the current test, +while only declaring the test-specific fields: + +.. code-block:: python + + class FooTests(unittest.TestCase): + + def test_with_factory_boy(self): + # We need a 200€, paid order, shipping to australia, for a VIP customer + order = OrderFactory( + amount=200, + status='PAID', + customer__is_vip=True, + address__country='AU', + ) + # Run the tests here + + def test_without_factory_boy(self): + address = Address( + street="42 fubar street", + zipcode="42Z42", + city="Sydney", + country="AU", + ) + customer = Customer( + first_name="John", + last_name="Doe", + phone="+1234", + email="john.doe@example.org", + active=True, + is_vip=True, + address=address, + ) + # etc. + +factory_boy is designed to work well with various ORMs (Django, MongoDB, SQLAlchemy), +and can easily be extended for other libraries. + +Its main features include: + +- Straightforward declarative syntax +- Chaining factory calls while retaining the global context +- Support for multiple build strategies (saved/unsaved instances, stubbed objects) - Multiple factories per class support, including inheritance -- Support for various ORMs (currently Django, Mogo, SQLAlchemy) Links ----- -* Documentation: http://factoryboy.readthedocs.org/ -* Official repository: https://github.com/rbarrois/factory_boy -* Package: https://pypi.python.org/pypi/factory_boy/ - -factory_boy supports Python 2.6, 2.7, 3.2 and 3.3, as well as PyPy; it requires only the standard Python library. +* Documentation: https://factoryboy.readthedocs.io/ +* Repository: https://github.com/FactoryBoy/factory_boy +* Package: https://pypi.org/project/factory-boy/ +* Mailing-list: `factoryboy@googlegroups.com `_ | https://groups.google.com/forum/#!forum/factoryboy Download -------- -PyPI: https://pypi.python.org/pypi/factory_boy/ +PyPI: https://pypi.org/project/factory-boy/ .. code-block:: sh $ pip install factory_boy -Source: https://github.com/rbarrois/factory_boy/ +Source: https://github.com/FactoryBoy/factory_boy/ .. code-block:: sh - $ git clone git://github.com/rbarrois/factory_boy/ + $ git clone git://github.com/FactoryBoy/factory_boy/ $ python setup.py install @@ -53,7 +112,8 @@ Usage Defining factories """""""""""""""""" -Factories declare a set of attributes used to instantiate an object. The class of the object must be defined in the FACTORY_FOR attribute: +Factories declare a set of attributes used to instantiate a Python object. +The class of the object must be defined in the ``model`` field of a ``class Meta:`` attribute: .. code-block:: python @@ -61,7 +121,8 @@ Factories declare a set of attributes used to instantiate an object. The class o from . import models class UserFactory(factory.Factory): - FACTORY_FOR = models.User + class Meta: + model = models.User first_name = 'John' last_name = 'Doe' @@ -69,31 +130,47 @@ Factories declare a set of attributes used to instantiate an object. The class o # Another, different, factory for the same object class AdminFactory(factory.Factory): - FACTORY_FOR = models.User + class Meta: + model = models.User first_name = 'Admin' last_name = 'User' admin = True +ORM integration +""""""""""""""" + +factory_boy integration with Object Relational Mapping (ORM) tools is provided +through specific ``factory.Factory`` subclasses: + +* Django, with ``factory.django.DjangoModelFactory`` +* Mogo, with ``factory.mogo.MogoFactory`` +* MongoEngine, with ``factory.mongoengine.MongoEngineFactory`` +* SQLAlchemy, with ``factory.alchemy.SQLAlchemyModelFactory`` + +More details can be found in the ORM section. + + Using factories """"""""""""""" -factory_boy supports several different build strategies: build, create, attributes and stub: +factory_boy supports several different instantiation strategies: build, create, and stub: .. code-block:: python # Returns a User instance that's not saved user = UserFactory.build() - # Returns a saved User instance + # Returns a saved User instance. + # UserFactory must subclass an ORM base class, such as DjangoModelFactory. user = UserFactory.create() - # Returns a dict of attributes that can be used to build a User instance - attributes = UserFactory.attributes() + # Returns a stub object (just a bunch of attributes) + obj = UserFactory.stub() -You can use the Factory class as a shortcut for the default build strategy: +You can use the Factory class as a shortcut for the default instantiation strategy: .. code-block:: python @@ -111,6 +188,53 @@ No matter which strategy is used, it's possible to override the defined attribut "Joe" +It is also possible to create a bunch of objects in a single call: + +.. code-block:: pycon + + >>> users = UserFactory.build_batch(10, first_name="Joe") + >>> len(users) + 10 + >>> [user.first_name for user in users] + ["Joe", "Joe", "Joe", "Joe", "Joe", "Joe", "Joe", "Joe", "Joe", "Joe"] + + +Realistic, random values +"""""""""""""""""""""""" + +Demos look better with random yet realistic values; and those realistic values can also help discover bugs. +For this, factory_boy relies on the excellent `faker `_ library: + +.. code-block:: python + + class RandomUserFactory(factory.Factory): + class Meta: + model = models.User + + first_name = factory.Faker('first_name') + last_name = factory.Faker('last_name') + +.. code-block:: pycon + + >>> RandomUserFactory() + + + +Reproducible random values +"""""""""""""""""""""""""" + +The use of fully randomized data in tests is quickly a problem for reproducing broken builds. +To that purpose, factory_boy provides helpers to handle the random seeds it uses, located in the ``factory.random`` module: + +.. code-block:: python + + import factory.random + + def setup_test_environment(): + factory.random.reseed_random('my_awesome_project') + # Other setup here + + Lazy Attributes """"""""""""""" @@ -123,10 +247,13 @@ These "lazy" attributes can be added as follows: .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = models.User + class Meta: + model = models.User + first_name = 'Joe' last_name = 'Blow' - email = factory.LazyAttribute(lambda a: '{0}.{1}@example.com'.format(a.first_name, a.last_name).lower()) + email = factory.LazyAttribute(lambda a: '{}.{}@example.com'.format(a.first_name, a.last_name).lower()) + date_joined = factory.LazyFunction(datetime.now) .. code-block:: pycon @@ -134,6 +261,10 @@ These "lazy" attributes can be added as follows: "joe.blow@example.com" +.. note:: ``LazyAttribute`` calls the function with the object being constructed as an argument, when + ``LazyFunction`` does not send any argument. + + Sequences """"""""" @@ -142,8 +273,10 @@ Unique values in a specific format (for example, e-mail addresses) can be genera .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = models.User - email = factory.Sequence(lambda n: 'person{0}@example.com'.format(n)) + class Meta: + model = models.User + + email = factory.Sequence(lambda n: 'person{}@example.com'.format(n)) >>> UserFactory().email 'person0@example.com' @@ -160,7 +293,9 @@ This is handled by the ``SubFactory`` helper: .. code-block:: python class PostFactory(factory.Factory): - FACTORY_FOR = models.Post + class Meta: + model = models.Post + author = factory.SubFactory(UserFactory) @@ -183,14 +318,25 @@ The associated object's strategy will be used: >>> post.author.id is None True +Support Policy +-------------- + +``factory_boy`` supports active Python versions as well as PyPy3. + +- **Python**'s `supported versions + `__. +- **Django**'s `supported + versions `__. +- **SQLAlchemy**: `latest version on PyPI `__. +- **MongoEngine**: `latest version on PyPI `__. Debugging factory_boy -""""""""""""""""""""" +--------------------- Debugging factory_boy can be rather complex due to the long chains of calls. Detailed logging is available through the ``factory`` logger. -A helper, :meth:`factory.debug()`, is available to ease debugging: +A helper, `factory.debug()`, is available to ease debugging: .. code-block:: python @@ -217,61 +363,91 @@ This will yield messages similar to those (artificial indentation): LazyStub: Computed values, got tests.test_using.TestModel2Factory(two=) BaseFactory: Generating tests.test_using.TestModel2Factory(two=) - -ORM Support -""""""""""" - -factory_boy has specific support for a few ORMs, through specific :class:`~factory.Factory` subclasses: - -* Django, with :class:`~factory.django.DjangoModelFactory` -* Mogo, with :class:`~factory.mogo.MogoFactory` -* MongoEngine, with :class:`~factory.mongoengine.MongoEngineFactory` -* SQLAlchemy, with :class:`~factory.alchemy.SQLAlchemyModelFactory` - Contributing ------------ factory_boy is distributed under the MIT License. -Issues should be opened through `GitHub Issues `_; whenever possible, a pull request should be included. +Issues should be opened through `GitHub Issues `_; whenever possible, a pull request should be included. +Questions and suggestions are welcome on the `mailing-list `_. -All pull request should pass the test suite, which can be launched simply with: +Development dependencies can be installed in a `virtualenv +`_ with: .. code-block:: sh - $ python setup.py test + $ pip install --editable '.[dev]' +All pull requests should pass the test suite, which can be launched simply with: -.. note:: +.. code-block:: sh + + $ make testall - Running test requires the unittest2 (standard in Python 2.7+) and mock libraries. In order to test coverage, please use: .. code-block:: sh - $ pip install coverage - $ coverage erase; coverage run --branch setup.py test; coverage report + $ make coverage + + +To test with a specific framework version, you may use a ``tox`` target: + +.. code-block:: sh + + # list all tox environments + $ tox --listenvs + # run tests inside a specific environment (django/mongoengine/SQLAlchemy are not installed) + $ tox -e py310 -Contents, indices and tables ----------------------------- + # run tests inside a specific environment (django) + $ tox -e py310-djangomain -.. toctree:: - :maxdepth: 2 + # run tests inside a specific environment (alchemy) + $ tox -e py310-alchemy - introduction - reference - orms - recipes - fuzzy - examples - internals - changelog - ideas + # run tests inside a specific environment (mongoengine) + $ tox -e py310-mongo -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +Packaging +--------- + +For users interesting in packaging FactoryBoy into downstream distribution channels +(e.g. ``.deb``, ``.rpm``, ``.ebuild``), the following tips might be helpful: + +Dependencies +"""""""""""" + +The package's run-time dependencies are listed in ``setup.cfg``. +The dependencies useful for building and testing the library are covered by the +``dev`` and ``doc`` extras. + +Moreover, all development / testing tasks are driven through ``make(1)``. + +Building +"""""""" + +In order to run the build steps (currently only for docs), run: + +.. code-block:: sh + + python setup.py egg_info + make doc + +Testing +""""""" + +When testing for the active Python environment, run the following: + +.. code-block:: sh + + make test + +.. note:: + + You must make sure that the ``factory`` module is importable, as it is imported from + the testing code. diff --git a/dev_requirements.txt b/dev_requirements.txt deleted file mode 100644 index e828644f..00000000 --- a/dev_requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -coverage -Django -Pillow -sqlalchemy -mongoengine \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile index 7a77848e..d4bb2cbb 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,130 +1,20 @@ -# Makefile for Sphinx documentation +# Minimal makefile for Sphinx documentation # -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . BUILDDIR = _build -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest - +# Put it first so that "make" without argument is like "make help". help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/FactoryBoy.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/FactoryBoy.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/FactoryBoy" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/FactoryBoy" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - make -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." +.PHONY: help Makefile -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/changelog.rst b/docs/changelog.rst index 09de7928..8985587a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,23 +1,536 @@ ChangeLog ========= +.. Note for v4.x: don't forget to check "Deprecated" sections for removal. + +3.3.4 (unreleased) +------------------ + +- Add support for Django 5.2 + + +3.3.3 (2025-02-03) +------------------ + +*New:* + + - Publish type annotations + + +3.3.2 (2025-02-03) +------------------ + +*Bugfix:* + + - Fix docs generation + +*New:* + + - Add support for Python 3.13 + + +3.3.1 (2024-08-18) +------------------ +*New:* + +- Add support for Django 4.2 +- Add support for Django 5.1 +- Add support for Python 3.12 +- :issue:`903`: Add basic typing annotations +- Run the test suite against ``mongomock`` instead of an actual MongoDB server + +*Bugfix:* + +- :issue:`1031`: Do not require :attr:`~factory.alchemy.SQLAlchemyOptions.sqlalchemy_session` when + :attr:`~factory.alchemy.SQLAlchemyOptions.sqlalchemy_session_factory` is provided. + +*Removed:* + +- Stop advertising and verifying support for Django 3.2, 4.0, 4.1 + +3.3.0 (2023-07-19) +------------------ + +*New:* + + - :issue:`366`: Add :class:`factory.django.Password` to generate Django :class:`~django.contrib.auth.models.User` + passwords. + - :issue:`304`: Add :attr:`~factory.alchemy.SQLAlchemyOptions.sqlalchemy_session_factory` to dynamically + create sessions for use by the :class:`~factory.alchemy.SQLAlchemyModelFactory`. + - Add support for Django 4.0 + - Add support for Django 4.1 + - Add support for Python 3.10 + - Add support for Python 3.11 + +*Bugfix:* + + - Make :meth:`~factory.django.mute_signals` mute signals during post-generation. + + - :issue:`775`: Change the signature for :class:`~factory.alchemy.SQLAlchemyModelFactory`'s ``_save`` and + ``_get_or_create`` methods to avoid argument names clashes with a field named ``session``. + +*Deprecated:* + + - :class:`~factory.django.DjangoModelFactory` will stop issuing a second call to + :meth:`~django.db.models.Model.save` on the created instance when :ref:`post-generation-hooks` return a value. + + To help with the transition, :class:`factory.django.DjangoModelFactory`'s ``_after_postgeneration`` raises a + :class:`DeprecationWarning` when calling :meth:`~django.db.models.Model.save`. Inspect your + :class:`~factory.django.DjangoModelFactory` subclasses: + + - If the :meth:`~django.db.models.Model.save` call is not needed after :class:`~factory.PostGeneration`, set + :attr:`factory.django.DjangoOptions.skip_postgeneration_save` to ``True`` in the factory meta. + + - Otherwise, the instance has been modified by :class:`~factory.PostGeneration` hooks and needs to be + :meth:`~django.db.models.Model.save`\ d. Either: + + - call :meth:`django.db.models.Model.save` in the :class:`~factory.PostGeneration` hook that modifies the + instance, or + - override the :class:`~factory.Factory._after_postgeneration` method to + :meth:`~django.db.models.Model.save` the instance. + +*Removed:* + + - Drop support for Django 2.2 + - Drop support for Django 3.0 + - Drop support for Django 3.1 + - Drop support for Python 3.6 + - Drop support for Python 3.7 + +3.2.1 (2021-10-26) +------------------ + +*New:* + - Add support for Django 3.2 + +*Bugfix:* + + - Do not override signals receivers registered in a :meth:`~factory.django.mute_signals` context. + + - :issue:`775`: Change the signature for :class:`~factory.alchemy.SQLAlchemyModelFactory`'s ``_save`` and + ``_get_or_create`` methods to avoid argument names clashes with a field named ``session``. + +3.2.0 (2020-12-28) +------------------ + +*New:* + + - Add support for Django 3.1 + - Add support for Python 3.9 + +*Removed:* + + - Drop support for Django 1.11. This version `is not maintained anymore `__. + - Drop support for Python 3.5. This version `is not maintained anymore `__. + +*Deprecated:* + + - :func:`factory.use_strategy`. Use :attr:`factory.FactoryOptions.strategy` instead. + The purpose of :func:`~factory.use_strategy` duplicates the factory option. Follow :pep:`20`: *There should be + one-- and preferably only one --obvious way to do it.* + + :func:`~factory.use_strategy()` will be removed in the next major version. + +*Bug fix:* + + - :issue:`785` :issue:`786` :issue:`787` :issue:`788` :issue:`790` :issue:`796`: Calls to :class:`factory.Faker` + and :class:`factory.django.FileField` within a :class:`~factory.Trait` or :class:`~factory.Maybe` no longer lead to + a ``KeyError`` crash. + + +3.1.0 (2020-10-02) +------------------ + +*New:* + + - Allow all types of declarations in :class:`factory.Faker` calls - enables references to other faker-defined attributes. + + +3.0.1 (2020-08-13) +------------------ + +*Bug fix:* + + - :issue:`769`: Fix ``import factory; factory.django.DjangoModelFactory`` and similar calls. + + +3.0.0 (2020-08-12) +------------------ + +Breaking changes +"""""""""""""""" + +The following aliases were removed: + ++================================================+===================================================+ +| Broken alias | New import | ++================================================+===================================================+ +| ``from factory import DjangoModelFactory`` | ``from factory.django import DjangoModelFactory`` | ++------------------------------------------------+---------------------------------------------------+ +| ``from factory import MogoFactory`` | ``from factory.mogo import MogoFactory`` | ++------------------------------------------------+---------------------------------------------------+ +| ``from factory.fuzzy import get_random_state`` | ``from factory.random import get_random_state`` | ++------------------------------------------------+---------------------------------------------------+ +| ``from factory.fuzzy import set_random_state`` | ``from factory.random import set_random_state`` | ++------------------------------------------------+---------------------------------------------------+ +| ``from factory.fuzzy import reseed_random`` | ``from factory.random import reseed_random`` | ++================================================+===================================================+ + +*Removed:* + + - Drop support for Python 2 and 3.4. These versions `are not maintained anymore `__. + - Drop support for Django 2.0 and 2.1. These versions `are not maintained anymore `__. + - Remove deprecated ``force_flush`` from ``SQLAlchemyModelFactory`` options. Use + ``sqlalchemy_session_persistence = "flush"`` instead. + - Drop deprecated ``attributes()`` from :class:`~factory.Factory` subclasses; use + ``factory.make_factory(dict, FactoryClass._meta.pre_declarations)`` instead. + - Drop deprecated ``declarations()`` from :class:`~factory.Factory` subclasses; use ``FactoryClass._meta.pre_declarations`` instead. + - Drop ``factory.compat`` module. + +*New:* + + - Add support for Python 3.8 + - Add support for Django 2.2 and 3.0 + - Report misconfiguration when a :py:class:`~factory.Factory` is used as the :py:attr:`~factory.FactoryOptions.model` for another :py:class:`~factory.Factory`. + - Allow configuring the color palette of :py:class:`~factory.django.ImageField`. + - :py:meth:`~factory.random.get_random_state()` now represents the state of Faker and ``factory_boy`` fuzzy attributes. + - Add SQLAlchemy ``get_or_create`` support + +*Improvements:* + + - :issue:`561`: Display a developer-friendly error message when providing a model instead of a factory in a :class:`~factory.SubFactory` class. + +*Bug fix:* + + - Fix issue with SubFactory not preserving signal muting behavior of the used factory, thanks `Patrick Stein `_. + - Fix issue with overriding parameters in a Trait, thanks `Grégoire Rocher `_. + - :issue:`598`: Limit ``get_or_create`` behavior to fields specified in ``django_get_or_create``. + - :issue:`606`: Re-raise :class:`~django.db.IntegrityError` when ``django_get_or_create`` with multiple fields fails to lookup model using user provided keyword arguments. + - :issue:`630`: TypeError masked by __repr__ AttributeError when initializing ``Maybe`` with inconsistent phases. + + +2.12.0 (2019-05-11) +------------------- + +*New:* + + - Add support for Python 3.7 + - Add support for Django 2.1 + - Add ``getter`` to :class:`~factory.fuzzy.FuzzyChoice` that mimics + the behavior of ``getter`` in :class:`~factory.Iterator` + - Make the ``extra_kwargs`` parameter of :class:`~factory.Faker`'s ``generate`` method optional + - Add :class:`~factory.RelatedFactoryList` class for one-to-many support, thanks `Sean Harrington `_. + - Make the `locale` argument for :class:`~factory.Faker` keyword-only + +*Bug fix:* + + - Allow renamed arguments to be optional, thanks to `Justin Crown `_. + - Fix `django_get_or_create` behavior when using multiple fields with `unique=True`, thanks to `@YPCrumble ` + + +2.11.1 (2018-05-05) +------------------- + +*Bug fix:* + + - Fix passing deep context to a :class:`~factory.SubFactory` (``Foo(x__y__z=factory.Faker('name')``) + + +2.11.0 (2018-05-05) +------------------- + +*Bug fix:* + + - Fix :class:`~factory.fuzzy.FuzzyFloat` to return a 15 decimal digits precision float by default + - :issue:`451`: Restore :class:`~factory.django.FileField` to a + ``factory.declarations.ParameteredAttribute``, relying on composition to parse the provided parameters. + - :issue:`389`: Fix random state management with ``faker``. + - :issue:`466`: Restore mixing :class:`~factory.Trait` and :meth:`~factory.post_generation`. + + +2.10.0 (2018-01-28) +------------------- + +*Bug fix:* + + - :issue:`443`: Don't crash when calling :meth:`factory.Iterator.reset()` on a brand new iterator. + +*New:* + + - :issue:`397`: Allow a :class:`factory.Maybe` to contain a :class:`~factory.PostGeneration` declaration. + This also applies to :class:`factory.Trait`, since they use a :class:`factory.Maybe` declaration internally. + +.. _v2.9.2: + +2.9.2 (2017-08-03) +------------------ + +*Bug fix:* + + - Fix declaration corruption bug when a factory defined `foo__bar__baz=1` and a caller + provided a `foo__bar=x` parameter at call time: this got merged into the factory's base + declarations. + +.. _v2.9.1: + +2.9.1 (2017-08-02) +------------------ + +*Bug fix:* + + - Fix packaging issues (see https://github.com/zestsoftware/zest.releaser/issues/212) + - Don't crash when debugging PostGenerationDeclaration + +.. _v2.9.0: + +2.9.0 (2017-07-30) +------------------ + +This version brings massive changes to the core engine, thus reducing the number of +corner cases and weird behaviors. + +*New:* + + - :issue:`275`: `factory.fuzzy` and `factory.faker` now use the same random seed. + - Add :class:`factory.Maybe`, which chooses among two possible declarations based + on another field's value (powers the :class:`~factory.Trait` feature). + - :class:`~factory.PostGenerationMethodCall` only allows to pass one positional argument; use keyword arguments for + extra parameters. + +*Deprecation:* + + - `factory.fuzzy.get_random_state` is deprecated, `factory.random.get_random_state` should be used instead. + - `factory.fuzzy.set_random_state` is deprecated, `factory.random.set_random_state` should be used instead. + - `factory.fuzzy.reseed_random` is deprecated, `factory.random.reseed_random` should be used instead. + +.. _v2.8.1: + +2.8.1 (2016-12-17) +------------------ + +*Bug fix:* + + - Fix packaging issues. + + +.. _v2.8.0: + +2.8.0 (2016-12-17) +------------------ + +*New:* + + - :issue:`240`: Call post-generation declarations in the order they were declared, + thanks to `Oleg Pidsadnyi `_. + - :issue:`309`: Provide new options for SQLAlchemy session persistence + +*Bug fix:* + + - :issue:`334`: Adjust for the package change in ``faker`` + + +.. _v2.7.0: + +2.7.0 (2016-04-19) +------------------ + +*New:* + + - :pr:`267`: Add :class:`factory.LazyFunction` to remove unneeded lambda parameters, + thanks to `Hervé Cauwelier `_. + - :issue:`251`: Add :ref:`parameterized factories ` and :class:`traits ` + - :pr:`256`, :pr:`292`: Improve error messages in corner cases + +*Removed:* + + - :pr:`278`: Formally drop support for Python2.6 + +.. warning:: Version 2.7.0 moves all error classes to + `factory.errors`. This breaks existing import statements + for any error classes except those importing + `FactoryError` directly from the `factory` module. + +.. _v2.6.1: + +2.6.1 (2016-02-10) +------------------ + +*New:* + + - :pr:`262`: Allow optional forced flush on SQLAlchemy, courtesy of `Minjung `_. + +.. _v2.6.0: + +2.6.0 (2015-10-20) +------------------ + +*New:* + + - Add :attr:`factory.FactoryOptions.rename` to help handle conflicting names (:issue:`206`) + - Add support for random-yet-realistic values through `fake-factory `_, + through the :class:`factory.Faker` class. + - :class:`factory.Iterator` no longer begins iteration of its argument at import time, + thus allowing to pass in a lazy iterator such as a Django queryset + (i.e ``factory.Iterator(models.MyThingy.objects.all())``). + - Simplify imports for ORM layers, now available through a simple ``factory`` import, + at ``factory.alchemy.SQLAlchemyModelFactory`` / ``factory.django.DjangoModelFactory`` / ``factory.mongoengine.MongoEngineFactory``. + +*Bug fix:* + + - :issue:`201`: Properly handle custom Django managers when dealing with abstract Django models. + - :issue:`212`: Fix :meth:`factory.django.mute_signals` to handle Django's signal caching + - :issue:`228`: Don't load ``django.apps.apps.get_model()`` until required + - :pr:`219`: Stop using ``mogo.model.Model.new()``, deprecated 4 years ago. + +.. _v2.5.2: + +2.5.2 (2015-04-21) +------------------ + +*Bug fix:* + + - Add support for Django 1.7/1.8 + - Add support for mongoengine>=0.9.0 / pymongo>=2.1 + +.. _v2.5.1: + +2.5.1 (2015-03-27) +------------------ + +*Bug fix:* + + - Respect custom managers in :class:`~factory.django.DjangoModelFactory` (see :issue:`192`) + - Allow passing declarations (e.g :class:`~factory.Sequence`) as parameters to :class:`~factory.django.FileField` + and :class:`~factory.django.ImageField`. + +.. _v2.5.0: + +2.5.0 (2015-03-26) +------------------ + +*New:* + + - Add support for getting/setting :mod:`factory.fuzzy`'s random state (see :issue:`175`, :issue:`185`). + - Support lazy evaluation of iterables in :class:`factory.fuzzy.FuzzyChoice` (see :issue:`184`). + - Support non-default databases at the factory level (see :issue:`171`) + - Make :class:`factory.django.FileField` and :class:`factory.django.ImageField` non-post_generation, i.e normal fields also available in ``save()`` (see :issue:`141`). + +*Bug fix:* + + - Avoid issues when using :meth:`factory.django.mute_signals` on a base factory class (see :issue:`183`). + - Fix limitations of :class:`factory.StubFactory`, that can now use :class:`factory.SubFactory` and co (see :issue:`131`). + + +*Deprecation:* + + - Remove deprecated features from :ref:`v2.4.0` + - Remove the auto-magical sequence setup (based on the latest primary key value in the database) for Django and SQLAlchemy; + this relates to issues :issue:`170`, :issue:`153`, :issue:`111`, :issue:`103`, :issue:`92`, :issue:`78`. See https://github.com/FactoryBoy/factory_boy/commit/13d310f for technical details. + +.. warning:: Version 2.5.0 removes the 'auto-magical sequence setup' bug-and-feature. + This could trigger some bugs when tests expected a non-zero sequence reference. + +Upgrading +""""""""" + +.. warning:: Version 2.5.0 removes features that were marked as deprecated in :ref:`v2.4.0 `. + +All ``FACTORY_*``-style attributes are now declared in a ``class Meta:`` section: + +.. code-block:: python + + # Old-style, deprecated + class MyFactory(factory.Factory): + FACTORY_FOR = models.MyModel + FACTORY_HIDDEN_ARGS = ['a', 'b', 'c'] + + # New-style + class MyFactory(factory.Factory): + class Meta: + model = models.MyModel + exclude = ['a', 'b', 'c'] + +A simple shell command to upgrade the code would be: + +.. code-block:: sh + + # sed -i: inplace update + # grep -l: only file names, not matching lines + sed -i 's/FACTORY_FOR =/class Meta:\n model =/' $(grep -l FACTORY_FOR $(find . -name '*.py')) + +This takes care of all ``FACTORY_FOR`` occurrences; the files containing other attributes to rename can be found with ``grep -R FACTORY .`` + + +.. _v2.4.1: + +2.4.1 (2014-06-23) +------------------ + +*Bug fix:* + + - Fix overriding deeply inherited attributes (set in one factory, overridden in a subclass, used in a sub-sub-class). + +.. _v2.4.0: + +2.4.0 (2014-06-21) +------------------ + +*New:* + + - Add support for :attr:`factory.fuzzy.FuzzyInteger.step`, thanks to `ilya-pirogov `_ (:pr:`120`) + - Add :meth:`~factory.django.mute_signals` decorator to temporarily disable some signals, thanks to `ilya-pirogov `_ (:pr:`122`) + - Add :class:`~factory.fuzzy.FuzzyFloat` (:issue:`124`) + - Declare target model and other non-declaration fields in a ``class Meta`` section. + +*Deprecation:* + + - Use of ``FACTORY_FOR`` and other ``FACTORY`` class-level attributes is deprecated and will be removed in 2.5. + Those attributes should now declared within the :class:`class Meta ` attribute: + + For :class:`factory.Factory`: + + * Rename ``factory.Factory.FACTORY_FOR`` to :attr:`~factory.FactoryOptions.model` + * Rename ``factory.Factory.ABSTRACT_FACTORY`` to :attr:`~factory.FactoryOptions.abstract` + * Rename ``factory.Factory.FACTORY_STRATEGY`` to :attr:`~factory.FactoryOptions.strategy` + * Rename ``factory.Factory.FACTORY_ARG_PARAMETERS`` to :attr:`~factory.FactoryOptions.inline_args` + * Rename ``factory.Factory.FACTORY_HIDDEN_ARGS`` to :attr:`~factory.FactoryOptions.exclude` + + For :class:`factory.django.DjangoModelFactory`: + + * Rename ``factory.django.DjangoModelFactory.FACTORY_DJANGO_GET_OR_CREATE`` to :attr:`~factory.django.DjangoOptions.django_get_or_create` + + For :class:`factory.alchemy.SQLAlchemyModelFactory`: + + * Rename ``factory.alchemy.SQLAlchemyModelFactory.FACTORY_SESSION`` to :attr:`~factory.alchemy.SQLAlchemyOptions.sqlalchemy_session` + +.. _v2.3.1: + +2.3.1 (2014-01-22) +------------------ + +*Bug fix:* + + - Fix badly written assert containing state-changing code, spotted by ``chsigi`` (:pr:`126`) + - Don't crash when handling objects whose ``__repr__`` is non-pure-ASCII bytes on Python 2, + discovered by `mbertheau `_ (:issue:`123`) and `strycore `_ (:pr:`127`) .. _v2.3.0: -2.3.0 (master) --------------- +2.3.0 (2013-12-25) +------------------ *New:* - - Add :class:`~factory.fuzzy.FuzzyText`, thanks to `jdufresne `_ (:issue:`97`) - - Add :class:`~factory.fuzzy.FuzzyDecimal`, thanks to `thedrow `_ (:issue:`94`) + - Add :class:`~factory.fuzzy.FuzzyText`, thanks to `jdufresne `_ (:pr:`97`) + - Add :class:`~factory.fuzzy.FuzzyDecimal`, thanks to `thedrow `_ (:pr:`94`) + - Add support for :class:`~mongoengine.EmbeddedDocument`, thanks to `imiric `_ (:pr:`100`) .. _v2.2.1: 2.2.1 (2013-09-24) ------------------ -*Bugfix:* +*Bug fix:* - Fixed sequence counter for :class:`~factory.django.DjangoModelFactory` when a factory inherits from another factory relating to an abstract model. @@ -27,13 +540,13 @@ ChangeLog 2.2.0 (2013-09-24) ------------------ -*Bugfix:* +*Bug fix:* - Removed duplicated :class:`~factory.alchemy.SQLAlchemyModelFactory` lurking in :mod:`factory` - (:issue:`83`) + (:pr:`83`) - Properly handle sequences within object inheritance chains. - If FactoryA inherits from FactoryB, and their associated classes share the same link, - sequence counters will be shared (:issue:`93`) + If ``FactoryA`` inherits from ``FactoryB``, and their associated classes + share the same link, sequence counters will be shared (:issue:`93`) - Properly handle nested :class:`~factory.SubFactory` overrides *New:* @@ -50,9 +563,9 @@ ChangeLog *New:* - - The :class:`~factory.Factory.ABSTRACT_FACTORY` keyword is now optional, and automatically set + - The ``factory.Factory.ABSTRACT_FACTORY`` keyword is now optional, and automatically set to ``True`` if neither the :class:`~factory.Factory` subclass nor its parent declare the - :class:`~factory.Factory.FACTORY_FOR` attribute (:issue:`74`) + ``factory.Factory.FACTORY_FOR`` attribute (:issue:`74`) .. _v2.1.1: @@ -60,7 +573,7 @@ ChangeLog 2.1.1 (2013-07-02) ------------------ -*Bugfix:* +*Bug fix:* - Properly retrieve the ``color`` keyword argument passed to :class:`~factory.django.ImageField` @@ -73,8 +586,8 @@ ChangeLog - Add :class:`~factory.fuzzy.FuzzyDate` thanks to `saulshanabrook `_ - Add :class:`~factory.fuzzy.FuzzyDateTime` and :class:`~factory.fuzzy.FuzzyNaiveDateTime`. - - Add a :attr:`~factory.containers.LazyStub.factory_parent` attribute to the - :class:`~factory.containers.LazyStub` passed to :class:`~factory.LazyAttribute`, in order to access + - Add a ``factory_parent`` attribute to the + ``factory.builder.Resolver`` passed to :class:`~factory.LazyAttribute`, in order to access fields defined in wrapping factories. - Move :class:`~factory.django.DjangoModelFactory` and :class:`~factory.mogo.MogoFactory` to their own modules (:mod:`factory.django` and :mod:`factory.mogo`) @@ -83,13 +596,13 @@ ChangeLog - Add debug messages to ``factory`` logger. - Add a :meth:`~factory.Iterator.reset` method to :class:`~factory.Iterator` (:issue:`63`) - Add support for the SQLAlchemy ORM through :class:`~factory.alchemy.SQLAlchemyModelFactory` - (:issue:`64`, thanks to `Romain Commandé `_) + (:pr:`64`, thanks to `Romain Commandé `_) - Add :class:`factory.django.FileField` and :class:`factory.django.ImageField` hooks for related Django model fields (:issue:`52`) -*Bugfix* +*Bug fix* - - Properly handle non-integer pks in :class:`~factory.django.DjangoModelFactory` (:issue:`57`). + - Properly handle non-integer primary keys in :class:`~factory.django.DjangoModelFactory` (:issue:`57`). - Disable :class:`~factory.RelatedFactory` generation when a specific value was passed (:issue:`62`, thanks to `Gabe Koscky `_) @@ -105,7 +618,7 @@ ChangeLog *New:* - - When :attr:`~factory.django.DjangoModelFactory.FACTORY_DJANGO_GET_OR_CREATE` is + - When ``factory.django.DjangoModelFactory.FACTORY_DJANGO_GET_OR_CREATE`` is empty, use ``Model.objects.create()`` instead of ``Model.objects.get_or_create``. @@ -117,7 +630,7 @@ ChangeLog *New:* - Don't push ``defaults`` to ``get_or_create`` when - :attr:`~factory.django.DjangoModelFactory.FACTORY_DJANGO_GET_OR_CREATE` is not set. + ``factory.django.DjangoModelFactory.FACTORY_DJANGO_GET_OR_CREATE`` is not set. .. _v2.0.0: @@ -129,11 +642,11 @@ ChangeLog - Allow overriding the base factory class for :func:`~factory.make_factory` and friends. - Add support for Python3 (Thanks to `kmike `_ and `nkryptic `_) - - The default :attr:`~factory.Sequence.type` for :class:`~factory.Sequence` is now :obj:`int` - - Fields listed in :attr:`~factory.Factory.FACTORY_HIDDEN_ARGS` won't be passed to + - The default type for :class:`~factory.Sequence` is now :obj:`int` + - Fields listed in ``factory.Factory.FACTORY_HIDDEN_ARGS`` won't be passed to the associated class' constructor - Add support for ``get_or_create`` in :class:`~factory.django.DjangoModelFactory`, - through :attr:`~factory.django.DjangoModelFactory.FACTORY_DJANGO_GET_OR_CREATE`. + through ``factory.django.DjangoModelFactory.FACTORY_DJANGO_GET_OR_CREATE``. - Add support for :mod:`~factory.fuzzy` attribute definitions. - The :class:`Sequence` counter can be overridden when calling a generating function - Add :class:`~factory.Dict` and :class:`~factory.List` declarations (Closes :issue:`18`). @@ -141,12 +654,12 @@ ChangeLog *Removed:* - Remove associated class discovery - - Remove :class:`~factory.InfiniteIterator` and :func:`~factory.infinite_iterator` - - Remove :class:`~factory.CircularSubFactory` + - Remove ``factory.InfiniteIterator`` and ``factory.infinite_iterator`` + - Remove ``factory.CircularSubFactory`` - Remove ``extract_prefix`` kwarg to post-generation hooks. - Stop defaulting to Django's ``Foo.objects.create()`` when "creating" instances - Remove STRATEGY_* - - Remove :meth:`~factory.Factory.set_building_function` / :meth:`~factory.Factory.set_creation_function` + - Remove ``factory.Factory.set_building_function`` / ``factory.Factory.set_creation_function`` .. _v1.3.0: @@ -170,8 +683,8 @@ New - **The Factory class:** - Better creation/building customization hooks at :meth:`factory.Factory._build` and :meth:`factory.Factory.create` - Add support for passing non-kwarg parameters to a :class:`~factory.Factory` - wrapped class through :attr:`~factory.Factory.FACTORY_ARG_PARAMETERS`. - - Keep the :attr:`~factory.Factory.FACTORY_FOR` attribute in :class:`~factory.Factory` classes + wrapped class through ``FACTORY_ARG_PARAMETERS``. + - Keep the ``FACTORY_FOR`` attribute in :class:`~factory.Factory` classes - **Declarations:** - Allow :class:`~factory.SubFactory` to solve circular dependencies between factories @@ -192,14 +705,14 @@ Pending deprecation The following features have been deprecated and will be removed in an upcoming release. - **Declarations:** - - :class:`~factory.InfiniteIterator` is deprecated in favor of :class:`~factory.Iterator` - - :class:`~factory.CircularSubFactory` is deprecated in favor of :class:`~factory.SubFactory` + - ``factory.InfiniteIterator`` is deprecated in favor of :class:`~factory.Iterator` + - ``factory.CircularSubFactory`` is deprecated in favor of :class:`~factory.SubFactory` - The ``extract_prefix`` argument to :meth:`~factory.post_generation` is now deprecated - **Factory:** - - Usage of :meth:`~factory.Factory.set_creation_function` and :meth:`~factory.Factory.set_building_function` + - Usage of ``factory.Factory.set_creation_function`` and ``factory.Factory.set_building_function`` are now deprecated - - Implicit associated class discovery is no longer supported, you must set the :attr:`~factory.Factory.FACTORY_FOR` + - Implicit associated class discovery is no longer supported, you must set the ``FACTORY_FOR`` attribute on all :class:`~factory.Factory` subclasses @@ -214,7 +727,7 @@ All warnings will turn into errors starting from v2.0.0. In order to upgrade client code, apply the following rules: - Add a ``FACTORY_FOR`` attribute pointing to the target class to each - :class:`~factory.Factory`, instead of relying on automagic associated class + :class:`~factory.Factory`, instead of relying on automatic associated class discovery - When using factory_boy for Django models, have each factory inherit from :class:`~factory.django.DjangoModelFactory` @@ -236,7 +749,7 @@ In order to upgrade client code, apply the following rules: *New:* - - Add :class:`~factory.CircularSubFactory` to solve circular dependencies between factories + - Add ``factory.CircularSubFactory`` to solve circular dependencies between factories .. _v1.1.5: @@ -244,9 +757,9 @@ In order to upgrade client code, apply the following rules: 1.1.5 (2012-07-09) ------------------ -*Bugfix:* +*Bug fix:* - - Fix :class:`~factory.PostGenerationDeclaration` and derived classes. + - Fix ``factory.PostGenerationDeclaration`` and derived classes. .. _v1.1.4: @@ -268,7 +781,7 @@ In order to upgrade client code, apply the following rules: 1.1.3 (2012-03-09) ------------------ -*Bugfix:* +*Bug fix:* - Fix packaging rules @@ -280,7 +793,7 @@ In order to upgrade client code, apply the following rules: *New:* - - Add :class:`~factory.Iterator` and :class:`~factory.InfiniteIterator` for :class:`~factory.Factory` attribute declarations. + - Add :class:`~factory.Iterator` and ``factory.InfiniteIterator`` for :class:`~factory.Factory` attribute declarations. - Provide :func:`~factory.Factory.generate` and :func:`~factory.Factory.simple_generate`, that allow specifying the instantiation strategy directly. Also provides :func:`~factory.Factory.generate_batch` and :func:`~factory.Factory.simple_generate_batch`. @@ -303,17 +816,17 @@ In order to upgrade client code, apply the following rules: *New:* - Improve the :class:`~factory.SelfAttribute` syntax to fetch sub-attributes using the ``foo.bar`` syntax; - - Add :class:`~factory.ContainerAttribute` to fetch attributes from the container of a :class:`~factory.SubFactory`. + - Add ``factory.ContainerAttribute`` to fetch attributes from the container of a :class:`~factory.SubFactory`. - Provide the :func:`~factory.make_factory` helper: ``MyClassFactory = make_factory(MyClass, x=3, y=4)`` - Add :func:`~factory.build`, :func:`~factory.create`, :func:`~factory.stub` helpers -*Bugfix:* +*Bug fix:* - - Allow classmethod/staticmethod on factories + - Allow ``classmethod``/``staticmethod`` on factories *Deprecation:* - - Auto-discovery of :attr:`~factory.Factory.FACTORY_FOR` based on class name is now deprecated + - Auto-discovery of ``factory.Factory.FACTORY_FOR`` based on class name is now deprecated .. _v1.0.4: @@ -326,17 +839,17 @@ In order to upgrade client code, apply the following rules: - Improve the algorithm for populating a :class:`~factory.Factory` attributes dict - Add ``python setup.py test`` command to run the test suite - Allow custom build functions - - Introduce :data:`~factory.MOGO_BUILD` build function + - Introduce ``factory.MOGO_BUILD`` build function - Add support for inheriting from multiple :class:`~factory.Factory` - - Base :class:`~factory.Factory` classes can now be declared :attr:`abstract `. + - Base :class:`~factory.Factory` classes can now be declared abstract through ``factory.Factory.ABSTRACT_FACTORY``. - Provide :class:`~factory.django.DjangoModelFactory`, whose :class:`~factory.Sequence` counter starts at the next free database id - Introduce :class:`~factory.SelfAttribute`, a shortcut for ``factory.LazyAttribute(lambda o: o.foo.bar.baz``. -*Bugfix:* +*Bug fix:* - Handle nested :class:`~factory.SubFactory` - Share sequence counter between parent and subclasses - - Fix :class:`~factory.SubFactory` / :class:`~factory.Sequence` interferences + - Fix :class:`~factory.SubFactory` / :class:`~factory.Sequence` interference .. _v1.0.2: @@ -359,7 +872,7 @@ In order to upgrade client code, apply the following rules: - Allow :class:`~factory.Factory` inheritance - Improve handling of custom build/create functions -*Bugfix:* +*Bug fix:* - Fix concurrency between :class:`~factory.LazyAttribute` and :class:`~factory.Sequence` @@ -377,8 +890,6 @@ In order to upgrade client code, apply the following rules: Credits ------- -* Initial version by Mark Sandstrom (2010) -* Developed by Raphaël Barrois since 2011 - +See :doc:`credits`. .. vim:et:ts=4:sw=4:tw=119:ft=rst: diff --git a/docs/conf.py b/docs/conf.py index 4f76d459..dc4a3863 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,31 +1,38 @@ -# -*- coding: utf-8 -*- +# Configuration file for the Sphinx documentation builder. # -# Factory Boy documentation build configuration file, created by -# sphinx-quickstart on Thu Sep 15 23:51:15 2011. -# -# This file is execfile()d with the current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- -import sys, os +import os +import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) sys.path.insert(0, os.path.dirname(os.path.abspath('.'))) -# -- General configuration ----------------------------------------------------- +# Must be imported after the parent directory was added to sys.path for global sphinx installation. +import factory # noqa + +# -- Project information ----------------------------------------------------- + +project = 'Factory Boy' +copyright = '2011-2015, Raphaël Barrois, Mark Sandstrom' +author = 'Raphaël Barrois, Mark Sandstrom' + +# The full version, including alpha/beta/rc tags +release = factory.__version__ +# The short X.Y version. +version = '.'.join(release.split('.')[:2]) -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# -- General configuration --------------------------------------------------- -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.extlinks', @@ -34,220 +41,61 @@ ] extlinks = { - 'issue': ('https://github.com/rbarrois/factory_boy/issues/%s', 'issue #'), + 'issue': ('https://github.com/FactoryBoy/factory_boy/issues/%s', 'issue %s'), + 'pr': ('https://github.com/FactoryBoy/factory_boy/pull/%s', 'pull request %s'), } # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] -# The suffix of source filenames. -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - # The master toctree document. master_doc = 'index' -# General information about the project. -project = u'Factory Boy' -copyright = u'2011-2013, Raphaël Barrois, Mark Sandstrom' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -root = os.path.abspath(os.path.dirname(__file__)) -def get_version(*module_dir_components): - import re - version_re = re.compile(r"^__version__ = ['\"](.*)['\"]$") - module_root = os.path.join(root, os.pardir, *module_dir_components) - module_init = os.path.join(module_root, '__init__.py') - with open(module_init, 'r') as f: - for line in f: - match = version_re.match(line[:-1]) - if match: - return match.groups()[0] - return '0.1.0' - -# The full version, including alpha/beta/rc tags. -release = get_version('factory') -# The short X.Y version. -version = '.'.join(release.split('.')[:2]) - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] -# The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# -- Options for HTML output --------------------------------------------------- +# -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = 'sphinx_rtd_theme' -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None +if 'READTHEDOCS_VERSION' in os.environ: + # Use the readthedocs version string in preference to our known version. + html_title = "{} {} documentation".format( + project, os.environ['READTHEDOCS_VERSION']) # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} +# -- linkcheck --------------------------------------------------------------- +linkcheck_retries = 3 -# If false, no module index is generated. -#html_domain_indices = True -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Output file base name for HTML help builder. -htmlhelp_basename = 'FactoryBoydoc' - - -# -- Options for LaTeX output -------------------------------------------------- - -# The paper size ('letter' or 'a4'). -#latex_paper_size = 'letter' - -# The font size ('10pt', '11pt' or '12pt'). -#latex_font_size = '10pt' - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ('index', 'FactoryBoy.tex', u'Factory Boy Documentation', - u'Raphaël Barrois, Mark Sandstrom', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Additional stuff for the LaTeX preamble. -#latex_preamble = '' - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output -------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'factoryboy', u'Factory Boy Documentation', - [u'Raphaël Barrois, Mark Sandstrom'], 1) -] - - -# Example configuration for intersphinx: refer to the Python standard library. +# -- intersphinx ------------------------------------------------------------- intersphinx_mapping = { - 'http://docs.python.org/': None, + 'python': ('https://docs.python.org/3', None), 'django': ( - 'http://docs.djangoproject.com/en/dev/', - 'http://docs.djangoproject.com/en/dev/_objects/', + 'https://docs.djangoproject.com/en/dev/', + 'https://docs.djangoproject.com/en/dev/_objects/', + ), + 'mongoengine': ( + 'https://mongoengine-odm.readthedocs.io/', + None, ), 'sqlalchemy': ( - 'http://docs.sqlalchemy.org/en/rel_0_8/', - 'http://docs.sqlalchemy.org/en/rel_0_8/objects.inv', + 'https://docs.sqlalchemy.org/en/latest/', + 'https://docs.sqlalchemy.org/en/latest/objects.inv', ), } + + +# -- spelling --------------------------------------------------------------- +spelling_exclude_patterns = [ + 'credits.rst', +] diff --git a/docs/credits.rst b/docs/credits.rst new file mode 120000 index 00000000..c7a76417 --- /dev/null +++ b/docs/credits.rst @@ -0,0 +1 @@ +../CREDITS \ No newline at end of file diff --git a/docs/examples.rst b/docs/examples.rst index aab990aa..bd27af6a 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -12,16 +12,17 @@ First, let's define a couple of objects: .. code-block:: python - class Account(object): - def __init__(self, username, email): + class Account: + def __init__(self, username, email, date_joined): self.username = username self.email = email + self.date_joined = date_joined def __str__(self): return '%s (%s)' % (self.username, self.email) - class Profile(object): + class Profile: GENDER_MALE = 'm' GENDER_FEMALE = 'f' @@ -34,11 +35,11 @@ First, let's define a couple of objects: self.lastname = lastname self.planet = planet - def __unicode__(self): - return u'%s %s (%s)' % ( - unicode(self.firstname), - unicode(self.lastname), - unicode(self.account.accountname), + def __str__(self): + return '%s %s (%s)' % ( + self.firstname, + self.lastname, + self.account.username, ) Factories @@ -49,30 +50,33 @@ And now, we'll define the related factories: .. code-block:: python + import datetime import factory - import random from . import objects class AccountFactory(factory.Factory): - FACTORY_FOR = objects.Account + class Meta: + model = objects.Account username = factory.Sequence(lambda n: 'john%s' % n) email = factory.LazyAttribute(lambda o: '%s@example.org' % o.username) + date_joined = factory.LazyFunction(datetime.datetime.now) class ProfileFactory(factory.Factory): - FACTORY_FOR = objects.Profile + class Meta: + model = objects.Profile account = factory.SubFactory(AccountFactory) gender = factory.Iterator([objects.Profile.GENDER_MALE, objects.Profile.GENDER_FEMALE]) - firstname = u'John' - lastname = u'Doe' + firstname = 'John' + lastname = 'Doe' -We have now defined basic factories for our :class:`~Account` and :class:`~Profile` classes. +We have now defined basic factories for our ``Account`` and ``Profile`` classes. If we commonly use a specific variant of our objects, we can refine a factory accordingly: @@ -81,8 +85,8 @@ If we commonly use a specific variant of our objects, we can refine a factory ac class FemaleProfileFactory(ProfileFactory): gender = objects.Profile.GENDER_FEMALE - firstname = u'Jane' - user__username = factory.Sequence(lambda n: 'jane%s' % n) + firstname = 'Jane' + account__username = factory.Sequence(lambda n: 'jane%s' % n) @@ -112,12 +116,9 @@ We can now use our factories, for tests: def test_get_profile_stats(self): profiles = [] - for _ in xrange(4): - profiles.append(factories.ProfileFactory()) - for _ in xrange(2): - profiles.append(factories.FemaleProfileFactory()) - for _ in xrange(2): - profiles.append(factories.ProfileFactory(planet='Tatooine')) + profiles.extend(factories.ProfileFactory.create_batch(4)) + profiles.extend(factories.FemaleProfileFactory.create_batch(2)) + profiles.extend(factories.ProfileFactory.create_batch(2, planet="Tatooine")) stats = business_logic.profile_stats(profiles) self.assertEqual({'Earth': 6, 'Mars': 2}, stats.planets) @@ -131,8 +132,7 @@ Or for fixtures: from . import factories def make_objects(): - for _ in xrange(50): - factories.ProfileFactory() + factories.ProfileFactory.create_batch(size=50) # Let's create a few, known objects. factories.ProfileFactory( diff --git a/docs/fuzzy.rst b/docs/fuzzy.rst index b94dfa58..bef86778 100644 --- a/docs/fuzzy.rst +++ b/docs/fuzzy.rst @@ -3,11 +3,18 @@ Fuzzy attributes .. module:: factory.fuzzy +.. note:: Now that FactoryBoy includes the :class:`factory.Faker` class, most of + these built-in fuzzers are deprecated in favor of their + `Faker `_ equivalents. Further + discussion in :issue:`271`. + Some tests may be interested in testing with fuzzy, random values. This is handled by the :mod:`factory.fuzzy` module, which provides a few random declarations. +.. note:: Use ``import factory.fuzzy`` to load this module. + FuzzyAttribute -------------- @@ -31,7 +38,7 @@ FuzzyText .. class:: FuzzyText(length=12, chars=string.ascii_letters, prefix='') The :class:`FuzzyText` fuzzer yields random strings beginning with - the given :attr:`prefix`, followed by :attr:`length` charactes chosen + the given :attr:`prefix`, followed by :attr:`length` characters chosen from the :attr:`chars` character set, and ending with the given :attr:`suffix`. @@ -62,8 +69,11 @@ FuzzyChoice The :class:`FuzzyChoice` fuzzer yields random choices from the given iterable. - .. note:: The passed in :attr:`choices` will be converted into a list at - declaration time. + .. note:: The passed in :attr:`choices` will be converted into a list upon + first use, not at declaration time. + + This allows passing in, for instance, a Django queryset that will + only hit the database during the database, not at import time. .. attribute:: choices @@ -73,7 +83,7 @@ FuzzyChoice FuzzyInteger ------------ -.. class:: FuzzyInteger(low[, high]) +.. class:: FuzzyInteger(low[, high[, step]]) The :class:`FuzzyInteger` fuzzer generates random integers within a given inclusive range. @@ -82,7 +92,7 @@ FuzzyInteger .. code-block:: pycon - >>> FuzzyInteger(0, 42) + >>> fi = FuzzyInteger(0, 42) >>> fi.low, fi.high 0, 42 @@ -98,12 +108,18 @@ FuzzyInteger int, the inclusive higher bound of generated integers + .. attribute:: step + + int, the step between values in the range; for instance, a ``FuzzyInteger(0, 42, step=3)`` + might only yield values from ``[0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42]``. + + FuzzyDecimal ------------ -.. class:: FuzzyDecimal(low[, high]) +.. class:: FuzzyDecimal(low[, high[, precision=2]]) - The :class:`FuzzyDecimal` fuzzer generates random integers within a given + The :class:`FuzzyDecimal` fuzzer generates random :class:`decimals ` within a given inclusive range. The :attr:`low` bound may be omitted, in which case it defaults to 0: @@ -134,6 +150,32 @@ FuzzyDecimal int, the number of digits to generate after the dot. The default is 2 digits. +FuzzyFloat +---------- + +.. class:: FuzzyFloat(low[, high]) + + The :class:`FuzzyFloat` fuzzer provides random :class:`float` objects within a given inclusive range. + + .. code-block:: pycon + + >>> FuzzyFloat(0.5, 42.7) + >>> fi.low, fi.high + 0.5, 42.7 + + >>> fi = FuzzyFloat(42.7) + >>> fi.low, fi.high + 0.0, 42.7 + + + .. attribute:: low + + decimal, the inclusive lower bound of generated floats + + .. attribute:: high + + decimal, the inclusive higher bound of generated floats + FuzzyDate --------- @@ -162,7 +204,7 @@ FuzzyDate FuzzyDateTime ------------- -.. class:: FuzzyDateTime(start_dt[, end_dt], tz=UTC, force_year=None, force_month=None, force_day=None, force_hour=None, force_minute=None, force_second=None, force_microsecond=None) +.. class:: FuzzyDateTime(start_dt[, end_dt], force_year=None, force_month=None, force_day=None, force_hour=None, force_minute=None, force_second=None, force_microsecond=None) The :class:`FuzzyDateTime` fuzzer generates random timezone-aware datetime within a given inclusive range. @@ -306,3 +348,10 @@ They should inherit from the :class:`BaseFuzzyAttribute` class, and override its The method responsible for generating random values. *Must* be overridden in subclasses. + + .. warning:: + + Custom :class:`BaseFuzzyAttribute` subclasses **MUST** + use :obj:`factory.random.randgen` as a randomness source; this ensures that + data they generate can be regenerated using the simple state from + :meth:`factory.random.get_random_state`. diff --git a/docs/ideas.rst b/docs/ideas.rst index 914e640f..b9f3691b 100644 --- a/docs/ideas.rst +++ b/docs/ideas.rst @@ -4,5 +4,6 @@ Ideas This is a list of future features that may be incorporated into factory_boy: -* **A 'options' attribute**: instead of adding more class-level constants, use a django-style ``class Meta`` Factory attribute with all options there - +* When a :class:`~factory.Factory` is built or created, pass the calling context throughout the calling chain instead of custom solutions everywhere +* Define a proper set of rules for the support of third-party ORMs +* Properly evaluate nested declarations (e.g ``factory.fuzzy.FuzzyDate(start_date=factory.SelfAttribute('since'))``) diff --git a/docs/index.rst b/docs/index.rst deleted file mode 120000 index 89a01069..00000000 --- a/docs/index.rst +++ /dev/null @@ -1 +0,0 @@ -../README.rst \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..10997f1d --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,24 @@ +.. include:: ../README.rst + + + +Contents, indices and tables +---------------------------- + +.. toctree:: + :maxdepth: 2 + + introduction + reference + orms + recipes + fuzzy + examples + internals + changelog + credits + ideas + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/internals.rst b/docs/internals.rst index a7402ff2..62ca3fed 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1,2 +1,90 @@ Internals ========= + +.. currentmodule:: factory + +Behind the scenes: steps performed when parsing a factory declaration, and when calling it. + + +This section will be based on the following factory declaration: + +.. literalinclude:: ../tests/test_docs_internals.py + :pyobject: UserFactory + + +Parsing, Step 1: Metaclass and type declaration +----------------------------------------------- + +1. Python parses the declaration and calls (thanks to the metaclass declaration): + + .. code-block:: python + + factory.base.BaseFactory.__new__( + 'UserFactory', + (factory.Factory,), + attributes, + ) + +2. That metaclass removes :attr:`~Factory.Meta` and :attr:`~Factory.Params` from the class attributes, + then generate the actual factory class (according to standard Python rules) +3. It initializes a :class:`FactoryOptions` object, and links it to the class + + +Parsing, Step 2: adapting the class definition +----------------------------------------------- + +1. The :class:`FactoryOptions` reads the options from the :attr:`class Meta ` declaration +2. It finds a few specific pointer (loading the model class, finding the reference + factory for the sequence counter, etc.) +3. It copies declarations and parameters from parent classes +4. It scans current class attributes (from ``vars()``) to detect pre/post declarations +5. Declarations are split among pre-declarations and post-declarations + (a raw value shadowing a post-declaration is seen as a post-declaration) + + +.. note:: A declaration for ``foo__bar`` will be converted into parameter ``bar`` + for declaration ``foo``. + + +Instantiating, Step 1: Converging entry points +---------------------------------------------- + +First, decide the strategy: + +- If the entry point is specific to a strategy (:meth:`~Factory.build`, + :meth:`~Factory.create_batch`, ...), use it +- If it is generic (:meth:`~Factory.generate`, :meth:`Factory.__call__`), + use the strategy defined at the :attr:`class Meta ` level + + +Then, we'll pass the strategy and passed-in overrides to the ``Factory._generate`` method. + +.. note:: According to the project road map, a future version will use a ``Factory._generate_batch`` at its core instead. + +A factory's ``Factory._generate`` function actually delegates to a ``StepBuilder()`` object. +This object will carry the overall "build an object" context (strategy, depth, and possibly other). + + +Instantiating, Step 2: Preparing values +--------------------------------------- + +1. The ``StepBuilder`` merges overrides with the class-level declarations +2. The sequence counter for this instance is initialized +3. A ``Resolver`` is set up with all those declarations, and parses them in order; + it will call each value's ``evaluate()`` method, including extra parameters. +4. If needed, the ``Resolver`` might recurse (through the ``StepBuilder``, e.g when + encountering a :class:`SubFactory`. + + +Instantiating, Step 3: Building the object +------------------------------------------ + +1. The ``StepBuilder`` fetches the attributes computed by the ``Resolver``. +2. It applies renaming/adjustment rules +3. It passes them to the ``FactoryOptions.instantiate`` method, which + forwards to the proper methods. +4. Post-declaration are applied (in declaration order) + + +.. note:: This document discusses implementation details; there is no guarantee that the + described methods names and signatures will be kept as is. diff --git a/docs/introduction.rst b/docs/introduction.rst index 41c6f7be..d3b169a0 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -18,10 +18,11 @@ Basic usage ----------- -Factories declare a set of attributes used to instantiate an object, whose class is defined in the FACTORY_FOR attribute: +Factories declare a set of attributes used to instantiate an object, whose class is defined in the ``class Meta``'s ``model`` attribute: - Subclass ``factory.Factory`` (or a more suitable subclass) -- Set its ``FACTORY_FOR`` attribute to the target class +- Add a ``class Meta:`` block +- Set its ``model`` attribute to the target class - Add defaults for keyword args to pass to the associated class' ``__init__`` method @@ -31,7 +32,8 @@ Factories declare a set of attributes used to instantiate an object, whose class from . import base class UserFactory(factory.Factory): - FACTORY_FOR = base.User + class Meta: + model = base.User firstname = "John" lastname = "Doe" @@ -56,7 +58,8 @@ A given class may be associated to many :class:`~factory.Factory` subclasses: .. code-block:: python class EnglishUserFactory(factory.Factory): - FACTORY_FOR = base.User + class Meta: + model = base.User firstname = "John" lastname = "Doe" @@ -64,7 +67,8 @@ A given class may be associated to many :class:`~factory.Factory` subclasses: class FrenchUserFactory(factory.Factory): - FACTORY_FOR = base.User + class Meta: + model = base.User firstname = "Jean" lastname = "Dupont" @@ -88,28 +92,68 @@ This is achieved with the :class:`~factory.Sequence` declaration: .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = models.User + class Meta: + model = models.User username = factory.Sequence(lambda n: 'user%d' % n) .. code-block:: pycon + >>> # The sequence counter starts at 0 by default + >>> UserFactory() + >>> UserFactory() + + >>> # A value can be provided for a sequence-driven field + >>> # but this still increments the sequence counter + >>> UserFactory(username="ada.lovelace") + >>> UserFactory() - + -.. note:: For more complex situations, you may also use the :meth:`~factory.@sequence` decorator (note that ``self`` is not added as first parameter): +.. note:: For more complex situations, you may also use the :meth:`@factory.sequence ` decorator (note that ``self`` is not added as first parameter): .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = models.User + class Meta: + model = models.User @factory.sequence def username(n): return 'user%d' % n + To set or reset the sequence counter see :ref:`Forcing a sequence counter `. + +LazyFunction +------------ + +In simple cases, calling a function is enough to compute the value. If that function doesn't depend on the object +being built, use :class:`~factory.LazyFunction` to call that function; it should receive a function taking no +argument and returning the value for the field: + +.. code-block:: python + + class LogFactory(factory.Factory): + class Meta: + model = models.Log + + timestamp = factory.LazyFunction(datetime.now) + +.. code-block:: pycon + + >>> LogFactory() + + + >>> # The LazyFunction can be overridden + >>> LogFactory(timestamp=now - timedelta(days=1)) + + + +.. note:: For complex cases when you happen to write a specific function, + the :meth:`@factory.lazy_attribute ` decorator should be more appropriate. + LazyAttribute ------------- @@ -121,7 +165,8 @@ taking the object being built and returning the value for the field: .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = models.User + class Meta: + model = models.User username = factory.Sequence(lambda n: 'user%d' % n) email = factory.LazyAttribute(lambda obj: '%s@example.com' % obj.username) @@ -129,7 +174,7 @@ taking the object being built and returning the value for the field: .. code-block:: pycon >>> UserFactory() - + >>> # The LazyAttribute handles overridden fields >>> UserFactory(username='john') @@ -137,16 +182,17 @@ taking the object being built and returning the value for the field: >>> # They can be directly overridden as well >>> UserFactory(email='doe@example.com') - + -.. note:: As for :class:`~factory.Sequence`, a :meth:`~factory.@lazy_attribute` decorator is available: +.. note:: As for :class:`~factory.Sequence`, a :meth:`@factory.lazy_attribute ` decorator is available: .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = models.User + class Meta: + model = models.User username = factory.Sequence(lambda n: 'user%d' % n) @@ -168,7 +214,8 @@ and update them with its own declarations: .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = base.User + class Meta: + model = base.User firstname = "John" lastname = "Doe" @@ -209,13 +256,14 @@ Non-kwarg arguments Some classes take a few, non-kwarg arguments first. -This is handled by the :data:`~factory.Factory.FACTORY_ARG_PARAMETERS` attribute: +This is handled by the :data:`~factory.FactoryOptions.inline_args` attribute: .. code-block:: python class MyFactory(factory.Factory): - FACTORY_FOR = MyClass - FACTORY_ARG_PARAMETERS = ('x', 'y') + class Meta: + model = MyClass + inline_args = ('x', 'y') x = 1 y = 2 @@ -227,6 +275,61 @@ This is handled by the :data:`~factory.Factory.FACTORY_ARG_PARAMETERS` attribute +Altering a factory's behavior: parameters and traits +---------------------------------------------------- + +Some classes are better described with a few, simple parameters, that aren't fields on the actual model. +In that case, use a :attr:`~factory.Factory.Params` declaration: + +.. code-block:: python + + class RentalFactory(factory.Factory): + class Meta: + model = Rental + + begin = factory.fuzzy.FuzzyDate(start_date=datetime.date(2000, 1, 1)) + end = factory.LazyAttribute(lambda o: o.begin + o.duration) + + class Params: + duration = 12 + +.. code-block:: pycon + + >>> RentalFactory(duration=0) + 2012-03-03> + >>> RentalFactory(duration=10) + 2012-12-26> + + +When many fields should be updated based on a flag, use :class:`Traits ` instead: + +.. code-block:: python + + class OrderFactory(factory.Factory): + status = 'pending' + shipped_by = None + shipped_on = None + + class Meta: + model = Order + + class Params: + shipped = factory.Trait( + status='shipped', + shipped_by=factory.SubFactory(EmployeeFactory), + shipped_on=factory.LazyFunction(datetime.date.today), + ) + +A trait is toggled by a single boolean value: + +.. code-block:: pycon + + >>> OrderFactory() + + >>> OrderFactory(shipped=True) + + + Strategies ---------- @@ -251,7 +354,8 @@ Calling a :class:`~factory.Factory` subclass will provide an object through the .. code-block:: python class MyFactory(factory.Factory): - FACTORY_FOR = MyClass + class Meta: + model = MyClass .. code-block:: pycon @@ -265,6 +369,4 @@ Calling a :class:`~factory.Factory` subclass will provide an object through the -The default strategy can ba changed by setting the class-level :attr:`~factory.Factory.FACTROY_STRATEGY` attribute. - - +The default strategy can be changed by setting the ``class Meta`` :attr:`~factory.FactoryOptions.strategy` attribute. diff --git a/docs/logo.png b/docs/logo.png new file mode 100644 index 00000000..ff94a1a6 Binary files /dev/null and b/docs/logo.png differ diff --git a/docs/logo.svg b/docs/logo.svg new file mode 100644 index 00000000..d6ed4196 --- /dev/null +++ b/docs/logo.svg @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/docs/make.bat b/docs/make.bat index e4ecc122..2119f510 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -1,170 +1,35 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. changes to make an overview over all changed/added/deprecated items - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\FactoryBoy.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\FactoryBoy.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -:end +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/orms.rst b/docs/orms.rst index b720ed13..d28e7ebf 100644 --- a/docs/orms.rst +++ b/docs/orms.rst @@ -1,3 +1,5 @@ +.. _orm: + Using factory_boy with ORMs =========================== @@ -11,11 +13,11 @@ adding dedicated features. Django ------ -.. currentmodule:: factory.django +.. module:: factory.django The first versions of factory_boy were designed specifically for Django, -but the library has now evolved to be framework-independant. +but the library has now evolved to be framework-independent. Most features should thus feel quite familiar to Django users. @@ -32,15 +34,28 @@ All factories for a Django :class:`~django.db.models.Model` should use the This class provides the following features: - * The :attr:`~factory.Factory.FACTORY_FOR` attribute also supports the ``'app.Model'`` + * The :attr:`~factory.FactoryOptions.model` attribute also supports the ``'app.Model'`` syntax * :func:`~factory.Factory.create()` uses :meth:`Model.objects.create() ` - * :func:`~factory.Factory._setup_next_sequence()` selects the next unused primary key value * When using :class:`~factory.RelatedFactory` or :class:`~factory.PostGeneration` attributes, the base object will be :meth:`saved ` once all post-generation hooks have run. - .. attribute:: FACTORY_DJANGO_GET_OR_CREATE + +.. class:: DjangoOptions(factory.base.FactoryOptions) + + The ``class Meta`` on a :class:`~DjangoModelFactory` supports extra parameters: + + .. attribute:: database + + .. versionadded:: 2.5.0 + + All queries to the related model will be routed to the given database. + It defaults to ``'default'``. + + .. attribute:: django_get_or_create + + .. versionadded:: 2.4.0 Fields whose name are passed in this list will be used to perform a :meth:`Model.objects.get_or_create() ` @@ -49,8 +64,9 @@ All factories for a Django :class:`~django.db.models.Model` should use the .. code-block:: python class UserFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = 'myapp.User' # Equivalent to ``FACTORY_FOR = myapp.models.User`` - FACTORY_DJANGO_GET_OR_CREATE = ('username',) + class Meta: + model = 'myapp.User' # Equivalent to ``model = myapp.models.User`` + django_get_or_create = ('username',) username = 'john' @@ -73,32 +89,86 @@ All factories for a Django :class:`~django.db.models.Model` should use the >>> User.objects.all() [, ] + .. warning:: When ``django_get_or_create`` is used, be aware that any new + values passed to the Factory are **not** used to update an existing model. + + .. code-block:: pycon + + >>> john = UserFactory(username="john") # Fetches the existing user + + + >>> john.email + "john@example.com" + + >>> john = UserFactory( # Fetches the existing user + >>> username="john", # and provides a new email value + >>> email="a_new_email@example.com" + >>> ) + + + >>> john.email # The email value was not updated + "john@example.com" + + .. attribute:: skip_postgeneration_save + + Transitional option to prevent :class:`~factory.django.DjangoModelFactory`'s + ``_after_postgeneration`` from issuing a duplicate call to + :meth:`~django.db.models.Model.save` on the created instance when + :class:`factory.PostGeneration` hooks return a value. + + +Extra fields +"""""""""""" + +.. class:: Password + + Applies :func:`~django.contrib.auth.hashers.make_password` to the + clear-text argument before to generate the object. -.. note:: If a :class:`DjangoModelFactory` relates to an :obj:`~django.db.models.Options.abstract` - model, be sure to declare the :class:`DjangoModelFactory` as abstract: + .. method:: __init__(self, password) - .. code-block:: python + :param str or None password: Default password. - class MyAbstractModelFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.MyAbstractModel - ABSTRACT_FACTORY = True + .. note:: When the ``password`` argument is ``None``, the resulting password is + unusable as if ``set_unusable_password()`` were used. This is distinct + from setting the password to an empty string. - class MyConcreteModelFactory(MyAbstractModelFactory): - FACTORY_FOR = models.MyConcreteModel + .. code-block:: python - Otherwise, factory_boy will try to get the 'next PK' counter from the abstract model. + class UserFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.User + + password = factory.django.Password('pw') + + .. code-block:: pycon + + >>> from django.contrib.auth.hashers import check_password + >>> # Create user with the default password from the factory. + >>> user = UserFactory.create() + >>> check_password('pw', user.password) + True + >>> # Override user password at call time. + >>> other_user = UserFactory.create(password='other_pw') + >>> check_password('other_pw', other_user.password) + True + >>> # Set unusable password + >>> no_password_user = UserFactory.create(password=None) + >>> no_password_user.has_usable_password() + False .. class:: FileField Custom declarations for :class:`django.db.models.FileField` - .. method:: __init__(self, from_path='', from_file='', data=b'', filename='example.dat') + .. method:: __init__(self, from_path='', from_file='', from_func='', data=b'', filename='example.dat') :param str from_path: Use data from the file located at ``from_path``, and keep its filename - :param file from_file: Use the contents of the provided file object; use its filename - if available + :param io.BytesIO from_file: Use the contents of the provided file object; use its filename + if available, unless ``filename`` is also provided. + :param Callable from_func: Use function that returns a file object :param bytes data: Use the provided bytes as file contents :param str filename: The filename for the FileField @@ -108,7 +178,8 @@ All factories for a Django :class:`~django.db.models.Model` should use the .. code-block:: python class MyFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.MyModel + class Meta: + model = models.MyModel the_file = factory.django.FileField(filename='the_file.dat') @@ -124,19 +195,21 @@ All factories for a Django :class:`~django.db.models.Model` should use the Custom declarations for :class:`django.db.models.ImageField` - .. method:: __init__(self, from_path='', from_file='', filename='example.jpg', width=100, height=100, color='green', format='JPEG') + .. method:: __init__(self, from_path='', from_file='', from_func='', filename='example.jpg', width=100, height=100, color='green', format='JPEG') :param str from_path: Use data from the file located at ``from_path``, and keep its filename - :param file from_file: Use the contents of the provided file object; use its filename + :param io.BytesIO from_file: Use the contents of the provided file object; use its filename if available + :param Callable from_func: Use function that returns a file object :param str filename: The filename for the ImageField :param int width: The width of the generated image (default: ``100``) :param int height: The height of the generated image (default: ``100``) :param str color: The color of the generated image (default: ``'green'``) :param str format: The image format (as supported by PIL) (default: ``'JPEG'``) + :param str palette: The image palette (as supported by PIL) (default: ``'RGB'``) -.. note:: If the value ``None`` was passed for the :class:`FileField` field, this will +.. note:: If the value ``None`` was passed for the :class:`ImageField` field, this will disable field generation: .. note:: Just as Django's :class:`django.db.models.ImageField` requires the @@ -145,7 +218,8 @@ All factories for a Django :class:`~django.db.models.Model` should use the .. code-block:: python class MyFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.MyModel + class Meta: + model = models.MyModel the_image = factory.django.ImageField(color='blue') @@ -157,10 +231,47 @@ All factories for a Django :class:`~django.db.models.Model` should use the None +Disabling signals +""""""""""""""""" + +Signals are often used to plug some custom code into external components code; +for instance to create ``Profile`` objects on-the-fly when a new ``User`` object is saved. + +This may interfere with finely tuned :class:`factories `, which would +create both using :class:`~factory.RelatedFactory`. + +To work around this problem, use the :meth:`mute_signals()` decorator/context manager: + +.. method:: mute_signals(signal1, ...) + + Disable the list of selected signals when calling the factory, and reactivate them upon leaving. + +.. code-block:: python + + # foo/factories.py + + import factory + + from . import models + from . import signals + + @factory.django.mute_signals(signals.pre_save, signals.post_save) + class FooFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.Foo + + # ... + + def make_chain(): + with factory.django.mute_signals(signals.pre_save, signals.post_save): + # pre_save/post_save won't be called here. + return SomeFactory(), SomeOtherFactory() + + Mogo ---- -.. currentmodule:: factory.mogo +.. module:: factory.mogo factory_boy supports `Mogo`_-style models, through the :class:`MogoFactory` class. @@ -182,7 +293,7 @@ factory_boy supports `Mogo`_-style models, through the :class:`MogoFactory` clas MongoEngine ----------- -.. currentmodule:: factory.mongoengine +.. module:: factory.mongoengine factory_boy supports `MongoEngine`_-style models, through the :class:`MongoEngineFactory` class. @@ -200,18 +311,51 @@ factory_boy supports `MongoEngine`_-style models, through the :class:`MongoEngin * :func:`~factory.Factory.create()` builds an instance through ``__init__`` then saves it. + .. note:: If the :attr:`associated class ` is a :class:`mongoengine.EmbeddedDocument`, + the :class:`~MongoEngineFactory`'s ``create`` function won't "save" it, since this wouldn't make sense. + + This feature makes it possible to use :class:`~factory.SubFactory` to create embedded document. + +A minimalist example: + +.. code-block:: python + + import mongoengine + + class Address(mongoengine.EmbeddedDocument): + street = mongoengine.StringField() + + class Person(mongoengine.Document): + name = mongoengine.StringField() + address = mongoengine.EmbeddedDocumentField(Address) + + import factory + + class AddressFactory(factory.mongoengine.MongoEngineFactory): + class Meta: + model = Address + + street = factory.Sequence(lambda n: 'street%d' % n) + + class PersonFactory(factory.mongoengine.MongoEngineFactory): + class Meta: + model = Person + + name = factory.Sequence(lambda n: 'name%d' % n) + address = factory.SubFactory(AddressFactory) + SQLAlchemy ---------- -.. currentmodule:: factory.alchemy +.. module:: factory.alchemy -Factoy_boy also supports `SQLAlchemy`_ models through the :class:`SQLAlchemyModelFactory` class. +Factory_boy also supports `SQLAlchemy`_ models through the :class:`SQLAlchemyModelFactory` class. -To work, this class needs an `SQLAlchemy`_ session object affected to "FACTORY_SESSION" class attribute. +To work, this class needs an `SQLAlchemy`_ session object affected to the :attr:`Meta.sqlalchemy_session ` attribute. -.. _SQLAlchemy: http://www.sqlalchemy.org/ +.. _SQLAlchemy: https://www.sqlalchemy.org/ .. class:: SQLAlchemyModelFactory(factory.Factory) @@ -219,24 +363,117 @@ To work, this class needs an `SQLAlchemy`_ session object affected to "FACTORY_S This class provides the following features: - * :func:`~factory.Factory.create()` uses :meth:`sqlalchemy.orm.session.Session.add` - * :func:`~factory.Factory._setup_next_sequence()` selects the next unused primary key value + * :func:`~factory.Factory.create()` uses :meth:`sqlalchemy.orm.Session.add` + + +.. class:: SQLAlchemyOptions(factory.base.FactoryOptions) + + In addition to the usual parameters available in :class:`class Meta `, + a :class:`SQLAlchemyModelFactory` also supports the following settings: + + .. attribute:: sqlalchemy_session + + SQLAlchemy session to use to communicate with the database when creating + an object through this :class:`SQLAlchemyModelFactory`. + + .. attribute:: sqlalchemy_session_factory + + .. versionadded:: 3.3.0 + + :class:`~collections.abc.Callable` returning a :class:`~sqlalchemy.orm.Session` instance to use to communicate + with the database. You can either provide the session through this attribute, or through + :attr:`~factory.alchemy.SQLAlchemyOptions.sqlalchemy_session`, but not both at the same time. + + .. code-block:: python + + from . import common + + class UserFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = User + sqlalchemy_session_factory = lambda: common.Session() + + username = 'john' + + .. attribute:: sqlalchemy_session_persistence + + Control the action taken by ``sqlalchemy_session`` at the end of a create call. + + Valid values are: + + * ``None``: do nothing + * ``'flush'``: perform a session :meth:`~sqlalchemy.orm.Session.flush` + * ``'commit'``: perform a session :meth:`~sqlalchemy.orm.Session.commit` + + The default value is ``None``. + + .. attribute:: sqlalchemy_get_or_create + + .. versionadded:: 3.0.0 - .. attribute:: FACTORY_SESSION + Fields whose name are passed in this list will be used to perform a + :meth:`Model.query.one_or_none() ` + or the usual :meth:`Session.add() `: + + .. code-block:: python + + class UserFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = User + sqlalchemy_session = session + sqlalchemy_get_or_create = ('username',) + + username = 'john' - Fields whose SQLAlchemy session object are passed will be used to communicate with the database + .. code-block:: pycon -A (very) simple exemple: + >>> User.query.all() + [] + >>> UserFactory() # Creates a new user + + >>> User.query.all() + [] + + >>> UserFactory() # Fetches the existing user + + >>> User.query.all() # No new user! + [] + + >>> UserFactory(username='jack') # Creates another user + + >>> User.query.all() + [, ] + + .. warning:: When ``sqlalchemy_get_or_create`` is used, be aware that any new + values passed to the Factory are **not** used to update an existing model. + + .. code-block:: pycon + + >>> john = UserFactory(username="john") # Fetches the existing user + + + >>> john.email + "john@example.com" + + >>> john = UserFactory( # Fetches the existing user + >>> username="john", # and provides a new email value + >>> email="a_new_email@example.com" + >>> ) + + + >>> john.email # The email value was not updated + "john@example.com" + + +A (very) simple example: .. code-block:: python from sqlalchemy import Column, Integer, Unicode, create_engine - from sqlalchemy.ext.declarative import declarative_base - from sqlalchemy.orm import scoped_session, sessionmaker + from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker - session = scoped_session(sessionmaker()) engine = create_engine('sqlite://') - session.configure(bind=engine) + session = scoped_session(sessionmaker(bind=engine)) Base = declarative_base() @@ -249,19 +486,129 @@ A (very) simple exemple: Base.metadata.create_all(engine) + import factory - class UserFactory(SQLAlchemyModelFactory): - FACTORY_FOR = User - FACTORY_SESSION = session # the SQLAlchemy session object + class UserFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = User + sqlalchemy_session = session # the SQLAlchemy session object id = factory.Sequence(lambda n: n) - name = factory.Sequence(lambda n: u'User %d' % n) + name = factory.Sequence(lambda n: 'User %d' % n) .. code-block:: pycon >>> session.query(User).all() [] >>> UserFactory() - + >>> session.query(User).all() - [] + [] + + +Managing sessions +""""""""""""""""" + +Since `SQLAlchemy`_ is a general purpose library, +there is no "global" session management system. + +The most common pattern when working with unit tests and ``factory_boy`` +is to use `SQLAlchemy`_'s :class:`sqlalchemy.orm.scoping.scoped_session`: + +* The test runner configures some project-wide :class:`~sqlalchemy.orm.scoped_session` +* Each :class:`~SQLAlchemyModelFactory` subclass uses this + :class:`~sqlalchemy.orm.scoped_session` as its :attr:`~SQLAlchemyOptions.sqlalchemy_session` +* The :meth:`~unittest.TestCase.tearDown` method of tests calls + :meth:`Session.remove ` + to reset the session. + +.. note:: See the excellent :ref:`SQLAlchemy guide on scoped_session ` + for details of :class:`~sqlalchemy.orm.scoped_session`'s usage. + + The basic idea is that declarative parts of the code (including factories) + need a simple way to access the "current session", + but that session will only be created and configured at a later point. + + The :class:`~sqlalchemy.orm.scoping.scoped_session` handles this, + by virtue of only creating the session when a query is sent to the database. + + +Here is an example layout: + +- A global (test-only?) file holds the :class:`~sqlalchemy.orm.scoped_session`: + +.. code-block:: python + + # myproject/test/common.py + + from sqlalchemy import orm + Session = orm.scoped_session(orm.sessionmaker()) + + +- All factory access it: + +.. code-block:: python + + # myproject/factories.py + + import factory + + from . import models + from .test import common + + class UserFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = models.User + + # Use the not-so-global scoped_session + # Warning: DO NOT USE common.Session()! + sqlalchemy_session = common.Session + + name = factory.Sequence(lambda n: "User %d" % n) + + +- The test runner configures the :class:`~sqlalchemy.orm.scoped_session` when it starts: + +.. code-block:: python + + # myproject/test/runtests.py + + import sqlalchemy + + from . import common + + def runtests(): + engine = sqlalchemy.create_engine('sqlite://') + + # It's a scoped_session, and now is the time to configure it. + common.Session.configure(bind=engine) + + run_the_tests + + +- :class:`test cases ` use this ``scoped_session``, + and clear it after each test (for isolation): + +.. code-block:: python + + # myproject/test/test_stuff.py + + import unittest + + from . import common + + class MyTest(unittest.TestCase): + + def setUp(self): + # Prepare a new, clean session + self.session = common.Session() + + def test_something(self): + u = factories.UserFactory() + self.assertEqual([u], self.session.query(User).all()) + + def tearDown(self): + # Rollback the session => no changes to the database + self.session.rollback() + # Remove it, so that the next test gets a new Session() + common.Session.remove() diff --git a/docs/recipes.rst b/docs/recipes.rst index c1f37009..457bcf9c 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -26,12 +26,38 @@ use the :class:`~factory.SubFactory` declaration: from . import models class UserFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.User + class Meta: + model = models.User first_name = factory.Sequence(lambda n: "Agent %03d" % n) group = factory.SubFactory(GroupFactory) +Choosing from a populated table +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If the target of the :class:`~django.db.models.ForeignKey` should be +chosen from a pre-populated table +(e.g :class:`django.contrib.contenttypes.models.ContentType`), +simply use a :class:`factory.Iterator` on the chosen queryset: + +.. code-block:: python + + import factory, factory.django + from . import models + + class UserFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.User + + language = factory.Iterator(models.Language.objects.all()) + +Here, ``models.Language.objects.all()`` is a +:class:`~django.db.models.query.QuerySet` and will only hit the database when +``factory_boy`` starts iterating on it, i.e on the first call to +``UserFactory``; thus avoiding DB queries at import time. + + Reverse dependencies (reverse ForeignKey) ----------------------------------------- @@ -53,76 +79,85 @@ use a :class:`~factory.RelatedFactory` declaration: # factories.py class UserFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.User + class Meta: + model = models.User - log = factory.RelatedFactory(UserLogFactory, 'user', action=models.UserLog.ACTION_CREATE) + log = factory.RelatedFactory( + UserLogFactory, + factory_related_name='user', + action=models.UserLog.ACTION_CREATE, + ) -When a :class:`UserFactory` is instantiated, factory_boy will call +When a ``UserFactory`` is instantiated, factory_boy will call ``UserLogFactory(user=that_user, action=...)`` just before returning the created ``User``. Example: Django's Profile -""""""""""""""""""""""""" +~~~~~~~~~~~~~~~~~~~~~~~~~ Django (<1.5) provided a mechanism to attach a ``Profile`` to a ``User`` instance, -using a :class:`~django.db.models.ForeignKey` from the ``Profile`` to the ``User``. +using a :class:`~django.db.models.OneToOneField` from the ``Profile`` to the ``User``. A typical way to create those profiles was to hook a post-save signal to the ``User`` model. -factory_boy allows to define attributes of such profiles dynamically when creating a ``User``: +Prior to version 2.9, the solution to this was to override the ``factory.Factory._generate`` method on the factory. + +Since version 2.9, the :meth:`~factory.django.mute_signals` decorator should be used: + .. code-block:: python + from django.db.models.signals import post_save + + @factory.django.mute_signals(post_save) class ProfileFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = my_models.Profile + class Meta: + model = my_models.Profile title = 'Dr' # We pass in profile=None to prevent UserFactory from creating another profile # (this disables the RelatedFactory) user = factory.SubFactory('app.factories.UserFactory', profile=None) + @factory.django.mute_signals(post_save) class UserFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = auth_models.User + class Meta: + model = auth_models.User username = factory.Sequence(lambda n: "user_%d" % n) # We pass in 'user' to link the generated Profile to our just-generated User # This will call ProfileFactory(user=our_new_user), thus skipping the SubFactory. - profile = factory.RelatedFactory(ProfileFactory, 'user') - - @classmethod - def _generate(cls, create, attrs): - """Override the default _generate() to disable the post-save signal.""" - - # Note: If the signal was defined with a dispatch_uid, include that in both calls. - post_save.disconnect(handler_create_user_profile, auth_models.User) - user = super(UserFactory, cls)._generate(create, attrs) - post_save.connect(handler_create_user_profile, auth_models.User) - return user + profile = factory.RelatedFactory(ProfileFactory, factory_related_name='user') .. OHAI_VIM:* .. code-block:: pycon - >>> u = UserFactory(profile__title=u"Lord") + >>> u = UserFactory(profile__title="Lord") >>> u.get_profile().title - u"Lord" + "Lord" -Such behaviour can be extended to other situations where a signal interferes with +Such behavior can be extended to other situations where a signal interferes with factory_boy related factories. +Any factories that call these classes with :class:`~factory.SubFactory` will also need to be decorated in the same manner. + +.. + _DEPRECATED: Release 4.0: post_generation and RelatedFactory will stop + issuing calls to save(). Refs issues 316 and 366. + .. note:: When any :class:`~factory.RelatedFactory` or :class:`~factory.post_generation` attribute is defined on the :class:`~factory.django.DjangoModelFactory` subclass, a second ``save()`` is performed *after* the call to ``_create()``. - Code working with signals should thus override the :meth:`~factory.Factory._generate` - method. + Code working with signals should thus use the :meth:`~factory.django.mute_signals` decorator -Simple ManyToMany ------------------ +Simple Many-to-many relationship +-------------------------------- Building the adequate link between two models depends heavily on the use case; factory_boy doesn't provide a "all in one tools" as for :class:`~factory.SubFactory` @@ -140,30 +175,30 @@ hook: class User(models.Model): name = models.CharField() - groups = models.ManyToMany(Group) + groups = models.ManyToManyField(Group) # factories.py class GroupFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.Group + class Meta: + model = models.Group name = factory.Sequence(lambda n: "Group #%s" % n) class UserFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.User + class Meta: + model = models.User name = "John Doe" @factory.post_generation def groups(self, create, extracted, **kwargs): - if not create: - # Simple build, do nothing. + if not create or not extracted: + # Simple build, or nothing to add, do nothing. return - if extracted: - # A list of groups were passed in, use them - for group in extracted: - self.groups.add(group) + # Add the iterable of groups using bulk addition + self.groups.add(*extracted) .. OHAI_VIM** @@ -174,13 +209,15 @@ But when ``UserFactory.create(groups=(group1, group2, group3))`` is called, the ``groups`` declaration will add passed in groups to the set of groups for the user. +For SQLAlchemy, change ``self.groups.add(group)`` in the above example to +``self.groups.append(group)``. -ManyToMany with a 'through' ---------------------------- +Many-to-many relation with a 'through' +-------------------------------------- -If only one link is required, this can be simply performed with a :class:`RelatedFactory`. -If more links are needed, simply add more :class:`RelatedFactory` declarations: +If only one link is required, this can be simply performed with a :class:`~factory.RelatedFactory`. +If more links are needed, simply add more :class:`~factory.RelatedFactory` declarations: .. code-block:: python @@ -190,7 +227,7 @@ If more links are needed, simply add more :class:`RelatedFactory` declarations: class Group(models.Model): name = models.CharField() - members = models.ManyToMany(User, through='GroupLevel') + members = models.ManyToManyField(User, through='GroupLevel') class GroupLevel(models.Model): user = models.ForeignKey(User) @@ -200,28 +237,42 @@ If more links are needed, simply add more :class:`RelatedFactory` declarations: # factories.py class UserFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.User + class Meta: + model = models.User name = "John Doe" class GroupFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.Group + class Meta: + model = models.Group name = "Admins" class GroupLevelFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.GroupLevel + class Meta: + model = models.GroupLevel user = factory.SubFactory(UserFactory) group = factory.SubFactory(GroupFactory) rank = 1 class UserWithGroupFactory(UserFactory): - membership = factory.RelatedFactory(GroupLevelFactory, 'user') + membership = factory.RelatedFactory( + GroupLevelFactory, + factory_related_name='user', + ) class UserWith2GroupsFactory(UserFactory): - membership1 = factory.RelatedFactory(GroupLevelFactory, 'user', group__name='Group1') - membership2 = factory.RelatedFactory(GroupLevelFactory, 'user', group__name='Group2') + membership1 = factory.RelatedFactory( + GroupLevelFactory, + factory_related_name='user', + group__name='Group1', + ) + membership2 = factory.RelatedFactory( + GroupLevelFactory, + factory_related_name='user', + group__name='Group2', + ) Whenever the ``UserWithGroupFactory`` is called, it will, as a post-generation hook, @@ -266,28 +317,273 @@ When a field of a related class should match one of the container: Here, we want: -- The User to have the lang of its country (``factory.SelfAttribute('country.lang')``) -- The Company owner to live in the country of the company (``factory.SelfAttribute('..country')``) +- The ``User`` to have the ``lang`` of its country (``factory.SelfAttribute('country.lang')``) +- The ``Company`` owner to live in the country of the company (``factory.SelfAttribute('..country')``) .. code-block:: python # factories.py class CountryFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.Country + class Meta: + model = models.Country name = factory.Iterator(["France", "Italy", "Spain"]) lang = factory.Iterator(['fr', 'it', 'es']) class UserFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.User + class Meta: + model = models.User name = "John" lang = factory.SelfAttribute('country.lang') country = factory.SubFactory(CountryFactory) class CompanyFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.Company + class Meta: + model = models.Company name = "ACME, Inc." country = factory.SubFactory(CountryFactory) owner = factory.SubFactory(UserFactory, country=factory.SelfAttribute('..country')) + +If the value of a field on the child factory is indirectly derived from a field on the parent factory, you will need to use LazyAttribute and poke the "factory_parent" attribute. + +This time, we want the company owner to live in a country neighboring the country of the company: + +.. code-block:: python + + class CompanyFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.Company + + name = "ACME, Inc." + country = factory.SubFactory(CountryFactory) + owner = factory.SubFactory(UserFactory, + country=factory.LazyAttribute(lambda o: get_random_neighbor(o.factory_parent.country))) + +Custom manager methods +---------------------- + +Sometimes you need a factory to call a specific manager method other than the +default :meth:`Model.objects.create() ` method: + +.. code-block:: python + + class UserFactory(factory.django.DjangoModelFactory): + class Meta: + model = UserenaSignup + + username = "l7d8s" + email = "my_name@example.com" + password = "my_password" + + @classmethod + def _create(cls, model_class, *args, **kwargs): + """Override the default ``_create`` with our custom call.""" + manager = cls._get_manager(model_class) + # The default would use ``manager.create(*args, **kwargs)`` + return manager.create_user(*args, **kwargs) + + +Forcing the sequence counter +---------------------------- + +A common pattern with factory_boy is to use a :class:`factory.Sequence` declaration +to provide varying values to attributes declared as unique. + +However, it is sometimes useful to force a given value to the counter, for instance +to ensure that tests are properly reproducible. + +factory_boy provides a few hooks for this: + + +Forcing the value on a per-call basis + In order to force the counter for a specific :class:`~factory.Factory` instantiation, + just pass the value in the ``__sequence=42`` parameter: + + .. code-block:: python + + class AccountFactory(factory.Factory): + class Meta: + model = Account + uid = factory.Sequence(lambda n: n) + name = "Test" + + .. code-block:: pycon + + >>> obj1 = AccountFactory(name="John Doe", __sequence=10) + >>> obj1.uid # Taken from the __sequence counter + 10 + >>> obj2 = AccountFactory(name="Jane Doe") + >>> obj2.uid # The base sequence counter hasn't changed + 1 + + +Resetting the counter globally + If all calls for a factory must start from a deterministic number, + use :meth:`factory.Factory.reset_sequence`; this will reset the counter + to its initial value (as defined by :meth:`factory.Factory._setup_next_sequence`). + + .. code-block:: pycon + + >>> AccountFactory().uid + 1 + >>> AccountFactory().uid + 2 + >>> AccountFactory.reset_sequence() + >>> AccountFactory().uid # Reset to the initial value + 1 + >>> AccountFactory().uid + 2 + + It is also possible to reset the counter to a specific value: + + .. code-block:: pycon + + >>> AccountFactory.reset_sequence(10) + >>> AccountFactory().uid + 10 + >>> AccountFactory().uid + 11 + + This recipe is most useful in a :class:`~unittest.TestCase`'s + :meth:`~unittest.TestCase.setUp` method. + + +Forcing the initial value for all projects + The sequence counter of a :class:`~factory.Factory` can also be set + automatically upon the first call through the + :meth:`~factory.Factory._setup_next_sequence` method; this helps when the + objects' attributes mustn't conflict with preexisting data. + + A typical example is to ensure that running a Python script twice will create + non-conflicting objects, by setting up the counter to "max used value plus one": + + .. code-block:: python + + class AccountFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.Account + + @classmethod + def _setup_next_sequence(cls): + try: + return models.Accounts.objects.latest('uid').uid + 1 + except models.Account.DoesNotExist: + return 1 + + .. code-block:: pycon + + >>> Account.objects.create(uid=42, name="Blah") + >>> AccountFactory.create() # Sets up the account number based on the latest uid + + +.. _recipe-random-management: + +Using reproducible randomness +----------------------------- + +Although using random values is great, it can provoke test flakiness. +factory_boy provides a few helpers for this. + +.. note:: Those methods will seed the random engine used in both :class:`factory.Faker` and :mod:`factory.fuzzy` objects. + + +Seeding the random engine + The simplest way to manage randomness is to push a selected seed when starting tests: + + .. code-block:: python + + import factory.random + # Pass in any value + factory.random.reseed_random('my awesome project') + + +Reproducing unseeded tests + A project might choose not to use an explicit random seed (for better fuzzing), + but still wishes to have reproducible tests. + + For such cases, use a combination of :meth:`factory.random.get_random_state()` + and :meth:`factory.random.set_random_state()`. + + Since the random state structure is implementation-specific, we recommend passing it around + as a base64-encoded pickle dump. + + .. code-block:: python + + class MyTestRunner: + + def setup_test_environment(self): + state = os.environ.get('TEST_RANDOM_STATE') + if state: + try: + decoded_state = pickle.loads(base64.b64decode(state.encode('ascii'))) + except ValueError: + decoded_state = None + if decoded_state: + factory.random.set_random_state(decoded_state) + else: + encoded_state = base64.b64encode(pickle.dumps(factory.random.get_random_state())) + print("Current random state: %s" % encoded_state.decode('ascii')) + super().setup_test_environment() + + + +Converting a factory's output to a dict +--------------------------------------- + +In order to inject some data to, say, a REST API, it can be useful to fetch the factory's data +as a dict. + +Internally, a factory will: + +1. Merge declarations and overrides from all sources (class definition, call parameters, ...) +2. Resolve them into a dict +3. Pass that dict as keyword arguments to the model's ``build`` / ``create`` function + + +In order to get a dict, we'll just have to swap the model; the easiest way is to use +:meth:`factory.build`: + +.. code-block:: python + + class UserFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.User + + first_name = factory.Sequence(lambda n: "Agent %03d" % n) # Agent 000, Agent 001, Agent 002 + username = factory.Faker('user_name') + +.. code-block:: pycon + + >>> factory.build(dict, FACTORY_CLASS=UserFactory) + {'first_name': "Agent 000", 'username': 'john_doe'} + + +Fuzzying Django model field choices +----------------------------------- + +When defining a :class:`~factory.fuzzy.FuzzyChoice` you can reuse the same choice list from the model field descriptor. + +Use the ``getter`` kwarg to select the first element from each choice tuple. + +.. code-block:: python + + class UserFactory(factory.Factory): + class Meta: + model = User + + # CATEGORY_CHOICES is a list of (key, title) tuples + category = factory.fuzzy.FuzzyChoice(User.CATEGORY_CHOICES, getter=lambda c: c[0]) + + +Django models with `GenericForeignKeys` +--------------------------------------- + +For model which uses `GenericForeignKey `_ + +.. literalinclude:: ../examples/django_demo/generic_foreignkey/models.py + +We can create factories like this: + +.. literalinclude:: ../examples/django_demo/generic_foreignkey/factories.py diff --git a/docs/reference.rst b/docs/reference.rst index 53584a0a..2122b9f7 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -1,7 +1,7 @@ Reference ========= -.. currentmodule:: factory +.. module:: factory This section offers an in-depth description of factory_boy features. @@ -11,37 +11,62 @@ For internals and customization points, please refer to the :doc:`internals` sec The :class:`Factory` class -------------------------- -.. class:: Factory +Meta options +"""""""""""" + +.. class:: FactoryOptions + + .. versionadded:: 2.4.0 - The :class:`Factory` class is the base of factory_boy features. + A :class:`Factory`'s behavior can be tuned through a few settings. + + For convenience, they are declared in a single ``class Meta`` attribute: + + .. code-block:: python - It accepts a few specific attributes (must be specified on class declaration): + class MyFactory(factory.Factory): + class Meta: + model = MyObject + abstract = False - .. attribute:: FACTORY_FOR + .. attribute:: model This optional attribute describes the class of objects to generate. If unset, it will be inherited from parent :class:`Factory` subclasses. - .. attribute:: ABSTRACT_FACTORY + .. versionadded:: 2.4.0 + + .. method:: get_model_class() + + Returns the actual model class (:attr:`FactoryOptions.model` might be the + path to the class; this function will always return a proper class). + + .. attribute:: abstract This attribute indicates that the :class:`Factory` subclass should not be used to generate objects, but instead provides some extra defaults. It will be automatically set to ``True`` if neither the :class:`Factory` - subclass nor its parents define the :attr:`~Factory.FACTORY_FOR` attribute. + subclass nor its parents define the :attr:`~FactoryOptions.model` attribute. + + .. warning:: This flag is reset to ``False`` when a :class:`Factory` subclasses + another one if a :attr:`~FactoryOptions.model` is set. + + .. versionadded:: 2.4.0 - .. attribute:: FACTORY_ARG_PARAMETERS + .. attribute:: inline_args Some factories require non-keyword arguments to their :meth:`~object.__init__`. - They should be listed, in order, in the :attr:`FACTORY_ARG_PARAMETERS` + They should be listed, in order, in the :attr:`inline_args` attribute: .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User - FACTORY_ARG_PARAMETERS = ('login', 'email') + class Meta: + model = User + inline_args = ('login', 'email') login = 'john' email = factory.LazyAttribute(lambda o: '%s@example.com' % o.login) @@ -53,24 +78,27 @@ The :class:`Factory` class >>> User('john', 'john@example.com', firstname="John") # actual call - .. attribute:: FACTORY_HIDDEN_ARGS + .. versionadded:: 2.4.0 + + .. attribute:: exclude While writing a :class:`Factory` for some object, it may be useful to have general fields helping defining others, but that should not be - passed to the target class; for instance, a field named 'now' that would + passed to the model class; for instance, a field named 'now' that would hold a reference time used by other objects. - Factory fields whose name are listed in :attr:`FACTORY_HIDDEN_ARGS` will + Factory fields whose name are listed in :attr:`exclude` will be removed from the set of args/kwargs passed to the underlying class; they can be any valid factory_boy declaration: .. code-block:: python class OrderFactory(factory.Factory): - FACTORY_FOR = Order - FACTORY_HIDDEN_ARGS = ('now',) + class Meta: + model = Order + exclude = ('now',) - now = factory.LazyAttribute(lambda o: datetime.datetime.utcnow()) + now = factory.LazyFunction(datetime.datetime.utcnow) started_at = factory.LazyAttribute(lambda o: o.now - datetime.timedelta(hours=1)) paid_at = factory.LazyAttribute(lambda o: o.now - datetime.timedelta(minutes=50)) @@ -83,20 +111,89 @@ The :class:`Factory` class >>> OrderFactory(now=datetime.datetime(2013, 4, 1, 10)) + .. versionadded:: 2.4.0 + + + .. attribute:: rename + + Sometimes, a model expects a field with a name already used by one + of :class:`Factory`'s methods. + + In this case, the :attr:`rename` attributes allows to define renaming + rules: the keys of the :attr:`rename` dict are those used in the + :class:`Factory` declarations, and their values the new name: + + .. code-block:: python + + class ImageFactory(factory.Factory): + # The model expects "attributes" + form_attributes = ['thumbnail', 'black-and-white'] + + class Meta: + model = Image + rename = {'form_attributes': 'attributes'} + + .. versionadded: 2.6.0 + + + .. attribute:: strategy + + Use this attribute to change the strategy used by a :class:`Factory`. + The default is :data:`CREATE_STRATEGY`. + + + +Attributes and methods +"""""""""""""""""""""" + + +.. class:: Factory + + + **Class-level attributes:** + + .. attribute:: Meta + .. attribute:: _meta + + .. versionadded:: 2.4.0 + + The :class:`FactoryOptions` instance attached to a :class:`Factory` class is available + as a :attr:`_meta` attribute. + + .. attribute:: Params + + .. versionadded:: 2.7.0 + + The extra parameters attached to a :class:`Factory` are declared through a :attr:`Params` + class. + See :ref:`the "Parameters" section ` for more information. + + .. attribute:: _options_class + + .. versionadded:: 2.4.0 + + If a :class:`Factory` subclass needs to define additional, extra options, it has to + provide a custom :class:`FactoryOptions` subclass. + + A pointer to that custom class should be provided as :attr:`_options_class` so that + the :class:`Factory`-building metaclass can use it instead. + **Base functions:** - The :class:`Factory` class provides a few methods for getting objects; - the usual way being to simply call the class: + .. classmethod:: __call__(**kwargs) - .. code-block:: pycon + The :class:`Factory` class provides a few methods for getting objects; + the usual way being to simply call the class: + + .. code-block:: pycon - >>> UserFactory() # Calls UserFactory.create() - >>> UserFactory(login='john') # Calls UserFactory.create(login='john') + >>> UserFactory() # Calls UserFactory.create() + >>> UserFactory(login='john') # Calls UserFactory.create(login='john') - Under the hood, factory_boy will define the :class:`Factory` - :meth:`~object.__new__` method to call the default :ref:`strategy ` - of the :class:`Factory`. + Under the hood, factory_boy will define the :class:`Factory` + :meth:`~object.__new__` method to call the default :ref:`strategy ` + of the :class:`Factory`. A specific strategy for getting instance can be selected by calling the @@ -108,7 +205,7 @@ The :class:`Factory` class .. classmethod:: build_batch(cls, size, **kwargs) - Provides a list of :obj:`size` instances from the :class:`Factory`, + Provides a list of ``size`` instances from the :class:`Factory`, through the 'build' strategy. @@ -118,7 +215,7 @@ The :class:`Factory` class .. classmethod:: create_batch(cls, size, **kwargs) - Provides a list of :obj:`size` instances from the :class:`Factory`, + Provides a list of ``size`` instances from the :class:`Factory`, through the 'create' strategy. @@ -128,16 +225,16 @@ The :class:`Factory` class .. classmethod:: stub_batch(cls, size, **kwargs) - Provides a list of :obj:`size` stubs from the :class:`Factory`. + Provides a list of ``size`` stubs from the :class:`Factory`. .. classmethod:: generate(cls, strategy, **kwargs) - Provide a new instance, with the provided :obj:`strategy`. + Provide a new instance, with the provided ``strategy``. .. classmethod:: generate_batch(cls, strategy, size, **kwargs) - Provides a list of :obj:`size` instances using the specified strategy. + Provides a list of ``size`` instances using the specified strategy. .. classmethod:: simple_generate(cls, create, **kwargs) @@ -146,14 +243,14 @@ The :class:`Factory` class .. classmethod:: simple_generate_batch(cls, create, size, **kwargs) - Provides a list of :obj:`size` instances, either built or created - according to :obj:`create`. + Provides a list of ``size`` instances, either built or created + according to ``create``. **Extension points:** A :class:`Factory` subclass may override a couple of class methods to adapt - its behaviour: + its behavior: .. classmethod:: _adjust_kwargs(cls, **kwargs) @@ -162,7 +259,7 @@ The :class:`Factory` class The :meth:`_adjust_kwargs` extension point allows for late fields tuning. It is called once keyword arguments have been resolved and post-generation - items removed, but before the :attr:`FACTORY_ARG_PARAMETERS` extraction + items removed, but before the :attr:`~FactoryOptions.inline_args` extraction phase. .. code-block:: python @@ -177,7 +274,6 @@ The :class:`Factory` class .. OHAI_VIM** - .. classmethod:: _setup_next_sequence(cls) This method will compute the first value to use for the sequence counter @@ -189,19 +285,19 @@ The :class:`Factory` class Subclasses may fetch the next free ID from the database, for instance. - .. classmethod:: _build(cls, target_class, *args, **kwargs) + .. classmethod:: _build(cls, model_class, *args, **kwargs) .. OHAI_VIM* This class method is called whenever a new instance needs to be built. - It receives the target class (provided to :attr:`FACTORY_FOR`), and + It receives the model class (provided to :attr:`~FactoryOptions.model`), and the positional and keyword arguments to use for the class once all has been computed. Subclasses may override this for custom APIs. - .. classmethod:: _create(cls, target_class, *args, **kwargs) + .. classmethod:: _create(cls, model_class, *args, **kwargs) .. OHAI_VIM* @@ -214,10 +310,12 @@ The :class:`Factory` class .. code-block:: python class BaseBackendFactory(factory.Factory): - ABSTRACT_FACTORY = True # Optional + class Meta: + abstract = True # Optional - def _create(cls, target_class, *args, **kwargs): - obj = target_class(*args, **kwargs) + @classmethod + def _create(cls, model_class, *args, **kwargs): + obj = model_class(*args, **kwargs) obj.save() return obj @@ -250,11 +348,13 @@ The :class:`Factory` class .. code-block:: pycon + >>> SomeFactory.build().sequenced_attribute + 0 >>> SomeFactory.reset_sequence(4) - >>> SomeFactory._next_sequence + >>> SomeFactory.build().sequenced_attribute 4 - Since subclasses of a non-:attr:`abstract ` + Since subclasses of a non-:attr:`abstract ` :class:`~factory.Factory` share the same sequence counter, special care needs to be taken when resetting the counter of such a subclass. @@ -279,6 +379,175 @@ The :class:`Factory` class factory in the chain. +.. _parameters: + +Parameters +"""""""""" + +.. versionadded:: 2.7.0 + +Some models have many fields that can be summarized by a few parameters; for instance, +a train with many cars — each complete with serial number, manufacturer, ...; +or an order that can be pending/shipped/received, with a few fields to describe each step. + +When building instances of such models, a couple of parameters can be enough to determine +all other fields; this is handled by the :class:`~Factory.Params` section of a :class:`Factory` declaration. + + +Simple parameters +~~~~~~~~~~~~~~~~~ + +Some factories only need little data: + +.. code-block:: python + + class ConferenceFactory(factory.Factory): + class Meta: + model = Conference + + class Params: + duration = 'short' # Or 'long' + + start_date = factory.fuzzy.FuzzyDate() + end_date = factory.LazyAttribute( + lambda o: o.start_date + datetime.timedelta(days=2 if o.duration == 'short' else 7) + ) + sprints_start = factory.LazyAttribute( + lambda o: o.end_date - datetime.timedelta(days=0 if o.duration == 'short' else 1) + ) + +.. code-block:: pycon + + >>> ConferenceFactory(duration='short') + + >>> ConferenceFactory(duration='long') + + + +Any simple parameter provided to the :class:`Factory.Params` section is available to the whole factory, +but not passed to the final class (similar to the :attr:`~FactoryOptions.exclude` behavior). + + +Traits +~~~~~~ + +.. class:: Trait(**kwargs) + + .. OHAI VIM** + + .. versionadded:: 2.7.0 + + A trait's parameters are the fields it should alter when enabled. + + +For more complex situations, it is helpful to override a few fields at once: + +.. code-block:: python + + class OrderFactory(factory.Factory): + class Meta: + model = Order + + state = 'pending' + shipped_on = None + shipped_by = None + + class Params: + shipped = factory.Trait( + state='shipped', + shipped_on=datetime.date.today(), + shipped_by=factory.SubFactory(EmployeeFactory), + ) + +Such a :class:`Trait` is activated or disabled by a single boolean field: + + +.. code-block:: pycon + + >>> OrderFactory() + + Order(state='pending') + >>> OrderFactory(shipped=True) + + + +A :class:`Trait` can be enabled/disabled by a :class:`Factory` subclass: + +.. code-block:: python + + class ShippedOrderFactory(OrderFactory): + shipped = True + + +Values set in a :class:`Trait` can be overridden by call-time values: + +.. code-block:: pycon + + >>> OrderFactory(shipped=True, shipped_on=last_year) + + + +:class:`Traits ` can be chained: + +.. code-block:: python + + class OrderFactory(factory.Factory): + class Meta: + model = Order + + # Can be pending/shipping/received + state = 'pending' + shipped_on = None + shipped_by = None + received_on = None + received_by = None + + class Params: + shipped = factory.Trait( + state='shipped', + shipped_on=datetime.date.today, + shipped_by=factory.SubFactory(EmployeeFactory), + ) + received = factory.Trait( + shipped=True, + state='received', + shipped_on=datetime.date.today - datetime.timedelta(days=4), + received_on=datetime.date.today, + received_by=factory.SubFactory(CustomerFactory), + ) + +.. code-block:: pycon + + >>> OrderFactory(received=True) + + + + +A :class:`Trait` might be overridden in :class:`Factory` subclasses: + +.. code-block:: python + + class LocalOrderFactory(OrderFactory): + + class Params: + received = factory.Trait( + shipped=True, + state='received', + shipped_on=datetime.date.today - datetime.timedelta(days=1), + received_on=datetime.date.today, + received_by=factory.SubFactory(CustomerFactory), + ) + + +.. code-block:: pycon + + >>> LocalOrderFactory(received=True) + + + +.. note:: When overriding a :class:`Trait`, the whole declaration **MUST** be replaced. + + .. _strategies: Strategies @@ -293,7 +562,7 @@ factory_boy supports two main strategies for generating instances, plus stubs. but not persisted to any datastore. It is usually a simple call to the :meth:`~object.__init__` method of the - :attr:`~Factory.FACTORY_FOR` class. + :attr:`~FactoryOptions.model` class. .. data:: CREATE_STRATEGY @@ -309,23 +578,16 @@ factory_boy supports two main strategies for generating instances, plus stubs. >>> obj.save() >>> return obj - .. OHAI_VIM* - - .. warning:: For backward compatibility reasons, the default behaviour of - factory_boy is to call ``MyClass.objects.create(*args, **kwargs)`` - when using the ``create`` strategy. - That policy will be used if the - :attr:`associated class ` has an ``objects`` - attribute *and* the :meth:`~Factory._create` classmethod of the - :class:`Factory` wasn't overridden. +.. function:: use_strategy(strategy) + .. deprecated:: 3.2 -.. function:: use_strategy(strategy) + Use :py:attr:`factory.FactoryOptions.strategy` instead. *Decorator* - Change the default strategy of the decorated :class:`Factory` to the chosen :obj:`strategy`: + Change the default strategy of the decorated :class:`Factory` to the chosen ``strategy``: .. code-block:: python @@ -337,14 +599,14 @@ factory_boy supports two main strategies for generating instances, plus stubs. .. data:: STUB_STRATEGY The 'stub' strategy is an exception in the factory_boy world: it doesn't return - an instance of the :attr:`~Factory.FACTORY_FOR` class, and actually doesn't + an instance of the :attr:`~FactoryOptions.model` class, and actually doesn't require one to be present. Instead, it returns an instance of :class:`StubObject` whose attributes have been set according to the declarations. -.. class:: StubObject(object) +.. class:: StubObject A plain, stupid object. No method, no helpers, simply a bunch of attributes. @@ -359,18 +621,18 @@ factory_boy supports two main strategies for generating instances, plus stubs. .. class:: StubFactory(Factory) - An :attr:`abstract ` :class:`Factory`, + An :attr:`abstract ` :class:`Factory`, with a default strategy set to :data:`STUB_STRATEGY`. .. function:: debug(logger='factory', stream=None) :param str logger: The name of the logger to enable debug for - :param file stream: The stream to send debug output to, defaults to :obj:`sys.stderr` + :param io.StringIO stream: The stream to send debug output to, defaults to :obj:`sys.stderr` Context manager to help debugging factory_boy behavior. It will temporarily put the target logger (e.g ``'factory'``) in debug mode, - sending all output to :obj`~sys.stderr`; + sending all output to ``stream``; upon leaving the context, the logging levels are reset. A typical use case is to understand what happens during a single factory call: @@ -400,6 +662,169 @@ factory_boy supports two main strategies for generating instances, plus stubs. Declarations ------------ + +Faker +""""" + +.. class:: Faker(provider, locale=None, **kwargs) + + .. OHAIVIM** + + In order to easily define realistic-looking factories, + use the :class:`Faker` attribute declaration. + + This is a wrapper around `faker `_; + its argument is the name of a ``faker`` provider: + + .. code-block:: python + + class UserFactory(factory.Factory): + class Meta: + model = User + + name = factory.Faker('name') + + .. code-block:: pycon + + >>> user = UserFactory() + >>> user.name + 'Lucy Cechtelar' + + Some providers accept parameters; they should be passed after the provider name: + + .. code-block:: python + + class UserFactory(factory.Factory): + class Meta: + model = User + + arrival = factory.Faker( + 'date_between_dates', + date_start=datetime.date(2020, 1, 1), + date_end=datetime.date(2020, 5, 31), + ) + + As with :class:`~factory.SubFactory`, the parameters can be any valid declaration. + This does not apply to the provider name or the locale. + + .. code-block:: python + + class TripFactory(factory.Factory): + class Meta: + model = Trip + + departure = factory.Faker( + 'date', + end_datetime=datetime.date.today(), + ) + arrival = factory.Faker( + 'date_between_dates', + date_start=factory.SelfAttribute('..departure'), + ) + + .. note:: When using :class:`~factory.SelfAttribute` or :class:`~factory.LazyAttribute` + in a :class:`factory.Faker` parameter, the current object is the declarations + provided to the :class:`~factory.Faker` declaration; go :ref:`up a level ` + to reach fields of the surrounding :class:`~factory.Factory`, as shown + in the ``SelfAttribute('..xxx')`` example above. + + .. attribute:: locale + + If a custom locale is required for one specific field, + use the ``locale`` parameter: + + .. code-block:: python + + class UserFactory(factory.Factory): + class Meta: + model = User + + name = factory.Faker('name', locale='fr_FR') + + .. code-block:: pycon + + >>> user = UserFactory() + >>> user.name + 'Jean Valjean' + + + .. classmethod:: override_default_locale(cls, locale) + + If the locale needs to be overridden for a whole test, + use :meth:`~factory.Faker.override_default_locale`: + + .. code-block:: pycon + + >>> with factory.Faker.override_default_locale('de_DE'): + ... UserFactory() + + + .. classmethod:: add_provider(cls, locale=None) + + Some projects may need to fake fields beyond those provided by ``faker``; + in such cases, use :meth:`factory.Faker.add_provider` to declare additional providers + for those fields: + + .. code-block:: python + + factory.Faker.add_provider(SmileyProvider) + + class FaceFactory(factory.Factory): + class Meta: + model = Face + + smiley = factory.Faker('smiley') + + +LazyFunction +"""""""""""" + +.. class:: LazyFunction(method_to_call) + +The :class:`LazyFunction` is the simplest case where the value of an attribute +does not depend on the object being built. + +It takes as an argument a function to call; that should not take any arguments and +return a value. + +.. code-block:: python + + class LogFactory(factory.Factory): + class Meta: + model = models.Log + + timestamp = factory.LazyFunction(datetime.now) + +.. code-block:: pycon + + >>> LogFactory() + + + >>> # The LazyFunction can be overridden + >>> LogFactory(timestamp=now - timedelta(days=1)) + + +:class:`LazyFunction` is also useful for assigning copies of mutable objects +(like lists) to an object's property. Example: + +.. code-block:: python + + DEFAULT_TEAM = ['Player1', 'Player2'] + + class TeamFactory(factory.Factory): + class Meta: + model = models.Team + + teammates = factory.LazyFunction(lambda: list(DEFAULT_TEAM)) + + +Decorator +~~~~~~~~~ + +The class :class:`LazyFunction` does not provide a decorator. + +For complex cases, use :meth:`~factory.lazy_attribute` directly. + LazyAttribute """"""""""""" @@ -414,7 +839,8 @@ accept the object being built as sole argument, and return a value. .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User username = 'john' email = factory.LazyAttribute(lambda o: '%s@example.com' % o.username) @@ -431,7 +857,7 @@ accept the object being built as sole argument, and return a value. The object passed to :class:`LazyAttribute` is not an instance of the target class, -but instead a :class:`~containers.LazyStub`: a temporary container that computes +but instead a ``builder.Resolver``: a temporary container that computes the value of all declared fields. @@ -449,9 +875,10 @@ return value of the method: .. code-block:: python class UserFactory(factory.Factory) - FACTORY_FOR = User + class Meta: + model = User - name = u"Jean" + name = "Jean" @factory.lazy_attribute def email(self): @@ -459,19 +886,60 @@ return value of the method: clean_name = (unicodedata.normalize('NFKD', self.name) .encode('ascii', 'ignore') .decode('utf8')) - return u'%s@example.com' % clean_name + return '%s@example.com' % clean_name .. code-block:: pycon - >>> joel = UserFactory(name=u"Joël") + >>> joel = UserFactory(name="Joël") >>> joel.email - u'joel@example.com' + 'joel@example.com' + + +Transformer +""""""""""" + +.. class:: Transformer(default_value, *, transform) + + .. versionadded:: 3.3.0 + +A :class:`Transformer` applies a ``transform`` function to the provided value +before to set the transformed value on the generated object. + +It expects one positional argument and one keyword argument: + +- ``default_value``: the default value, which passes through the ``transform`` + function. +- ``transform``: a function taking the value as parameter and returning the + transformed value, + +.. code-block:: python + + class UpperFactory(factory.Factory): + name = factory.Transformer("Joe", transform=str.upper) + + class Meta: + model = Upper + +.. code-block:: pycon + + >>> UpperFactory().name + 'JOE' + >>> UpperFactory(name="John").name + 'JOHN' + +Disabling +~~~~~~~~~ +To disable a :class:`Transformer`, wrap the value in ``Transformer.Force``: +.. code-block:: pycon + + >>> UpperFactory(name=factory.Transformer.Force("John")).name + 'John' Sequence """""""" -.. class:: Sequence(lambda, type=int) +.. class:: Sequence(lambda) If a field should be unique, and thus different for all built instances, use a :class:`Sequence`. @@ -479,25 +947,23 @@ use a :class:`Sequence`. This declaration takes a single argument, a function accepting a single parameter - the current sequence counter - and returning the related value. - -.. note:: An extra kwarg argument, ``type``, may be provided. - This feature is deprecated in 1.3.0 and will be removed in 2.0.0. - - .. code-block:: python class UserFactory(factory.Factory) - FACTORY_FOR = User + class Meta: + model = User phone = factory.Sequence(lambda n: '123-555-%04d' % n) .. code-block:: pycon >>> UserFactory().phone - '123-555-0001' + '123-555-0000' >>> UserFactory().phone - '123-555-0002' + '123-555-0001' +.. note:: The sequence counter starts at 0 and can be set or reset, + see :ref:`Forcing a sequence counter `. Decorator ~~~~~~~~~ @@ -512,7 +978,8 @@ be the sequence counter - this might be confusing: .. code-block:: python class UserFactory(factory.Factory) - FACTORY_FOR = User + class Meta: + model = User @factory.sequence def phone(n): @@ -522,9 +989,9 @@ be the sequence counter - this might be confusing: .. code-block:: pycon - >>> UserFactory().phone + >>> UserFactory().phone # current sequence counter at 9999 '000-555-9999' - >>> UserFactory().phone + >>> UserFactory().phone # current sequence counter at 10000 '001-555-0000' @@ -537,7 +1004,8 @@ The sequence counter is shared across all :class:`Sequence` attributes of the .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User phone = factory.Sequence(lambda n: '%04d' % n) office = factory.Sequence(lambda n: 'A23-B%03d' % n) @@ -546,22 +1014,24 @@ The sequence counter is shared across all :class:`Sequence` attributes of the >>> u = UserFactory() >>> u.phone, u.office - '0041', 'A23-B041' + '0040', 'A23-B040' >>> u2 = UserFactory() >>> u2.phone, u2.office - '0042', 'A23-B042' + '0041', 'A23-B041' Inheritance ~~~~~~~~~~~ -When a :class:`Factory` inherits from another :class:`Factory`, their -sequence counter is shared: +When a :class:`Factory` inherits from another :class:`Factory` and the `model` +of the subclass inherits from the `model` of the parent, the sequence counter +is shared across the :class:`Factory` classes: .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User phone = factory.Sequence(lambda n: '123-555-%04d' % n) @@ -573,16 +1043,17 @@ sequence counter is shared: >>> u = UserFactory() >>> u.phone - '123-555-0001' + '123-555-0000' >>> e = EmployeeFactory() >>> e.phone, e.office_phone - '123-555-0002', '0002' + '123-555-0001', '0001' >>> u2 = UserFactory() >>> u2.phone - '123-555-0003' + '123-555-0002' +.. _forcing-a-sequence-counter: Forcing a sequence counter ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -596,7 +1067,8 @@ class-level value. .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User uid = factory.Sequence(int) @@ -631,7 +1103,8 @@ It takes a single argument, a function whose two parameters are, in order: .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User login = 'john' email = factory.LazyAttributeSequence(lambda o, n: '%s@s%d.example.com' % (o.login, n)) @@ -639,9 +1112,9 @@ It takes a single argument, a function whose two parameters are, in order: .. code-block:: pycon >>> UserFactory().email - 'john@s1.example.com' + 'john@s0.example.com' >>> UserFactory(login='jack').email - 'jack@s2.example.com' + 'jack@s1.example.com' Decorator @@ -655,7 +1128,8 @@ handles more complex cases: .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User login = 'john' @@ -686,13 +1160,14 @@ The :class:`SubFactory` attribute should be called with: .. note:: When passing an actual :class:`~factory.Factory` for the - :attr:`~factory.SubFactory.factory` argument, make sure to pass + :class:`~factory.SubFactory`'s ``factory`` argument, make sure to pass the class and not instance (i.e no ``()`` after the class): .. code-block:: python class FooFactory(factory.Factory): - FACTORY_FOR = Foo + class Meta: + model = Foo bar = factory.SubFactory(BarFactory) # Not BarFactory() @@ -705,7 +1180,8 @@ Definition # A standard factory class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User # Various fields first_name = 'John' @@ -714,7 +1190,8 @@ Definition # A factory for an object with a 'User' field class CompanyFactory(factory.Factory): - FACTORY_FOR = Company + class Meta: + model = Company name = factory.Sequence(lambda n: 'FactoryBoyz' + 'z' * n) @@ -794,13 +1271,15 @@ This issue can be handled by passing the absolute import path to the target .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User username = 'john' main_group = factory.SubFactory('users.factories.GroupFactory') class GroupFactory(factory.Factory): - FACTORY_FOR = Group + class Meta: + model = Group name = "MyGroup" owner = factory.SubFactory(UserFactory) @@ -827,10 +1306,11 @@ That declaration takes a single argument, a dot-delimited path to the attribute .. code-block:: python - class UserFactory(factory.Factory) - FACTORY_FOR = User + class UserFactory(factory.Factory): + class Meta: + model = User - birthdate = factory.Sequence(lambda n: datetime.date(2000, 1, 1) + datetime.timedelta(days=n)) + birthdate = factory.fuzzy.FuzzyDate() birthmonth = factory.SelfAttribute('birthdate.month') .. code-block:: pycon @@ -842,6 +1322,8 @@ That declaration takes a single argument, a dot-delimited path to the attribute 3 +.. _factory-parent: + Parents ~~~~~~~ @@ -854,13 +1336,15 @@ gains an "upward" semantic through the double-dot notation, as used in Python im .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User language = 'en' class CompanyFactory(factory.Factory): - FACTORY_FOR = Company + class Meta: + model = Company country = factory.SubFactory(CountryFactory) owner = factory.SubFactory(UserFactory, language=factory.SelfAttribute('..country.language')) @@ -873,7 +1357,7 @@ gains an "upward" semantic through the double-dot notation, as used in Python im >>> company.owner.language 'fr' -Obviously, this "follow parents" hability also handles overriding some attributes on call: +Obviously, this "follow parents" ability also handles overriding some attributes on call: .. code-block:: pycon @@ -883,12 +1367,13 @@ Obviously, this "follow parents" hability also handles overriding some attribute This feature is also available to :class:`LazyAttribute` and :class:`LazyAttributeSequence`, -through the :attr:`~containers.LazyStub.factory_parent` attribute of the passed-in object: +through the ``factory_parent`` attribute of the passed-in object: .. code-block:: python class CompanyFactory(factory.Factory): - FACTORY_FOR = Company + class Meta: + model = Company country = factory.SubFactory(CountryFactory) owner = factory.SubFactory(UserFactory, language=factory.LazyAttribute(lambda user: user.factory_parent.country.language), @@ -900,7 +1385,7 @@ Iterator .. class:: Iterator(iterable, cycle=True, getter=None) - The :class:`Iterator` declaration takes succesive values from the given + The :class:`Iterator` declaration takes successive values from the given iterable. When it is exhausted, it starts again from zero (unless ``cycle=False``). .. attribute:: cycle @@ -910,7 +1395,7 @@ Iterator .. versionadded:: 1.3.0 The ``cycle`` argument is available as of v1.3.0; previous versions - had a behaviour equivalent to ``cycle=False``. + had a behavior equivalent to ``cycle=False``. .. attribute:: getter @@ -953,6 +1438,7 @@ When a value is passed in for the argument, the iterator will *not* be advanced: >>> UserFactory().lang 'fr' + .. _iterator-getter: Getter @@ -966,7 +1452,8 @@ adequate value. .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User # CATEGORY_CHOICES is a list of (key, title) tuples category = factory.Iterator(User.CATEGORY_CHOICES, getter=lambda c: c[0]) @@ -987,7 +1474,8 @@ use the :func:`iterator` decorator: .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User @factory.iterator def name(): @@ -996,6 +1484,15 @@ use the :func:`iterator` decorator: yield line +.. warning:: Values from the underlying iterator are *kept* in memory; once the + initial iterator has been emptied, saved values are used instead of + executing the function instead. + + Use ``factory.Iterator(my_func, cycle=False)`` to disable value + recycling. + + + Resetting ~~~~~~~~~ @@ -1030,7 +1527,8 @@ with the :class:`Dict` and :class:`List` attributes: .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User is_superuser = False roles = factory.Dict({ @@ -1048,7 +1546,7 @@ with the :class:`Dict` and :class:`List` attributes: containing factory's one. - The :class:`Dict` behaviour can be tuned through the following parameters: + The :class:`Dict` behavior can be tuned through the following parameters: .. attribute:: dict_factory @@ -1066,7 +1564,8 @@ with the :class:`Dict` and :class:`List` attributes: .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User flags = factory.List([ 'user', @@ -1081,7 +1580,7 @@ with the :class:`Dict` and :class:`List` attributes: ['user', 'active', 'superadmin'] - The :class:`List` behaviour can be tuned through the following parameters: + The :class:`List` behavior can be tuned through the following parameters: .. attribute:: list_factory @@ -1089,6 +1588,47 @@ with the :class:`Dict` and :class:`List` attributes: argument, if another type (tuple, set, ...) is required. +Maybe +""""" + +.. class:: Maybe(decider, yes_declaration, no_declaration) + +Sometimes, the way to build a given field depends on the value of another, +for instance of a parameter. + +In those cases, use the :class:`~factory.Maybe` declaration: +it takes the name of a "decider" boolean field, and two declarations; depending on +the value of the field whose name is held in the 'decider' parameter, it will +apply the effects of one or the other declaration: + +.. code-block:: python + + class UserFactory(factory.Factory): + class Meta: + model = User + + is_active = True + deactivation_date = factory.Maybe( + 'is_active', + yes_declaration=None, + no_declaration=factory.fuzzy.FuzzyDateTime(timezone.now() - datetime.timedelta(days=10)), + ) + +.. code-block:: pycon + + >>> u = UserFactory(is_active=True) + >>> u.deactivation_date + None + >>> u = UserFactory(is_active=False) + >>> u.deactivation_date + datetime.datetime(2017, 4, 1, 23, 21, 23, tzinfo=UTC) + +.. note:: If the condition for the decider is complex, use a :class:`LazyAttribute` + defined in the :attr:`~Factory.Params` section of your factory to + handle the computation. + +.. _post-generation-hooks: + Post-generation hooks """"""""""""""""""""" @@ -1100,6 +1640,10 @@ To support this pattern, factory_boy provides the following tools: - :class:`PostGeneration`: this class allows calling a given function with the generated object as argument - :func:`post_generation`: decorator performing the same functions as :class:`PostGeneration` - :class:`RelatedFactory`: this builds or creates a given factory *after* building/creating the first Factory. + - :class:`RelatedFactoryList`: this builds or creates a *list* of the given factory *after* building/creating the first Factory. + +Post-generation hooks are called in the same order they are declared in the factory class, so that +functions can rely on the side effects applied by the previous post-generation hook. Extracting parameters @@ -1113,10 +1657,11 @@ For instance, a :class:`PostGeneration` hook is declared as ``post``: .. code-block:: python class SomeFactory(factory.Factory): - FACTORY_FOR = SomeObject + class Meta: + model = SomeObject @post_generation - def post(self, create, extracted, **kwargs): + def post(obj, create, extracted, **kwargs): obj.set_origin(create) .. OHAI_VIM** @@ -1128,7 +1673,7 @@ When calling the factory, some arguments will be extracted for this method: - Any argument starting with ``post__XYZ`` will be extracted, its ``post__`` prefix removed, and added to the kwargs passed to the post-generation hook. -Extracted arguments won't be passed to the :attr:`~Factory.FACTORY_FOR` class. +Extracted arguments won't be passed to the :attr:`~FactoryOptions.model` class. Thus, in the following call: @@ -1142,7 +1687,7 @@ Thus, in the following call: ) The ``post`` hook will receive ``1`` as ``extracted`` and ``{'y': 3, 'z__t': 42}`` -as keyword arguments; ``{'post_x': 2}`` will be passed to ``SomeFactory.FACTORY_FOR``. +as keyword arguments; ``{'post_x': 2}`` will be passed to ``SomeFactory._meta.model``. RelatedFactory @@ -1165,43 +1710,30 @@ RelatedFactory - Or the fully qualified path to a :class:`Factory` subclass (see :ref:`subfactory-circular` for details) - .. attribute:: name - - The generated object (where the :class:`RelatedFactory` attribute will - set) may be passed to the related factory if the :attr:`factory_related_name` parameter - is set. - - It will be passed as a keyword argument, using the :attr:`name` value as - keyword: - - -.. note:: - - When passing an actual :class:`~factory.Factory` for the - :attr:`~factory.RelatedFactory.factory` argument, make sure to pass - the class and not instance (i.e no ``()`` after the class): - - .. code-block:: python - - class FooFactory(factory.Factory): - FACTORY_FOR = Foo - - bar = factory.RelatedFactory(BarFactory) # Not BarFactory() + .. attribute:: factory_related_name + If set, the object generated by the factory declaring the + ``RelatedFactory`` is passed as keyword argument to the related factory. .. code-block:: python class CityFactory(factory.Factory): - FACTORY_FOR = City + class Meta: + model = City capital_of = None name = "Toronto" class CountryFactory(factory.Factory): - FACTORY_FOR = Country + class Meta: + model = Country lang = 'fr' - capital_city = factory.RelatedFactory(CityFactory, 'capital_of', name="Paris") + capital_city = factory.RelatedFactory( + CityFactory, # Not CityFactory() + factory_related_name='capital_of', + name="Paris", + ) .. code-block:: pycon @@ -1218,7 +1750,7 @@ Extra kwargs may be passed to the related factory, through the usual ``ATTR__SUB >>> City.objects.get(capital_of=england) -If a value if passed for the :class:`RelatedFactory` attribute, this disables +If a value is passed for the :class:`RelatedFactory` attribute, this disables :class:`RelatedFactory` generation: .. code-block:: pycon @@ -1235,16 +1767,85 @@ If a value if passed for the :class:`RelatedFactory` attribute, this disables 1 +.. note:: The target of the :class:`RelatedFactory` is evaluated *after* the initial factory has been instantiated. + However, the build context is passed down to that factory; this means that calls to + :class:`factory.SelfAttribute` *can* go back to the calling factory's context: + + .. code-block:: python + + class CountryFactory(factory.Factory): + class Meta: + model = Country + + lang = 'fr' + capital_city = factory.RelatedFactory( + CityFactory, + factory_related_name='capital_of', + # Would also work with SelfAttribute('capital_of.lang') + main_lang=factory.SelfAttribute('..lang'), + ) + +RelatedFactoryList +"""""""""""""""""" + +.. class:: RelatedFactoryList(factory, factory_related_name='', size=2, **kwargs) + + .. OHAI_VIM** + + A :class:`RelatedFactoryList` behaves like a :class:`RelatedFactory`, only it returns a + list of factories. This is useful for simulating one-to-many relations, rather than the + one-to-one relation generated by :class:`RelatedFactory`. + + + .. attribute:: factory + + As for :class:`SubFactory`, the :attr:`factory` argument can be: + + - A :class:`Factory` subclass + - Or the fully qualified path to a :class:`Factory` subclass + (see :ref:`subfactory-circular` for details) + + .. attribute:: factory_related_name + + If set, the object generated by the factory declaring the + ``RelatedFactory`` is passed as keyword argument to the related factory. + + .. attribute:: size + + Either an ``int``, or a ``lambda`` that returns an ``int``, which will define the number + of related Factories to be generated for each parent object. + + .. versionadded:: 2.12 + + Note that the API for :class:`RelatedFactoryList` is considered experimental, and might change + in a future version for increased consistency with other declarations. + +.. note:: + Note that using a ``lambda`` for ``size`` allows the number of related objects per + parents object to vary. This is useful for testing, when you likely don't want your mock + data to have parent objects with the exact same, static number of related objects. + + .. code-block:: python + + class FooFactory(factory.Factory): + class Meta: + model = Foo + # Generate a list of `factory` objects of random size, ranging from 1 -> 5 + bar = factory.RelatedFactoryList(BarFactory, size=lambda: random.randint(1, 5)) + # Each Foo object will have exactly 3 Bar objects generated for its foobar attribute. + foobar = factory.RelatedFactoryList(BarFactory, size=3) + + PostGeneration """""""""""""" .. class:: PostGeneration(callable) -The :class:`PostGeneration` declaration performs actions once the target object +The :class:`PostGeneration` declaration performs actions once the model object has been generated. Its sole argument is a callable, that will be called once the base object has - been generated. +been generated. Once the base object has been generated, the provided callable will be called as ``callable(obj, create, extracted, **kwargs)``, where: @@ -1260,7 +1861,8 @@ as ``callable(obj, create, extracted, **kwargs)``, where: .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User login = 'john' make_mbox = factory.PostGeneration( @@ -1274,23 +1876,23 @@ Decorator .. function:: post_generation A decorator is also provided, decorating a single method accepting the same -``obj``, ``created``, ``extracted`` and keyword arguments as :class:`PostGeneration`. +``obj``, ``create``, ``extracted`` and keyword arguments as :class:`PostGeneration`. .. code-block:: python class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User login = 'john' @factory.post_generation - def mbox(self, create, extracted, **kwargs): + def mbox(obj, create, extracted, **kwargs): if not create: return - path = extracted or os.path.join('/tmp/mbox/', self.login) + path = extracted or os.path.join('/tmp/mbox/', obj.login) os.path.makedirs(path) - return path .. OHAI_VIM** @@ -1305,7 +1907,7 @@ A decorator is also provided, decorating a single method accepting the same PostGenerationMethodCall """""""""""""""""""""""" -.. class:: PostGenerationMethodCall(method_name, *args, **kwargs) +.. class:: PostGenerationMethodCall(method_name, *arg, **kwargs) .. OHAI_VIM* @@ -1316,11 +1918,11 @@ PostGenerationMethodCall .. attribute:: method_name - The name of the method to call on the :attr:`~Factory.FACTORY_FOR` object + The name of the method to call on the :attr:`~FactoryOptions.model` object - .. attribute:: args + .. attribute:: arg - The default set of unnamed arguments to pass to the method given in + The default, optional, positional argument to pass to the method given in :attr:`method_name` .. attribute:: kwargs @@ -1333,111 +1935,49 @@ Once the factory instance has been generated, the method specified in with any arguments specified in the :class:`PostGenerationMethodCall` declaration, by default. -For example, to set a default password on a generated User instance -during instantiation, we could make a declaration for a ``password`` -attribute like below: +For example, we could use ``PostGenerationMethodCall`` to register created +users in an external system. .. code-block:: python - class UserFactory(factory.Factory): - FACTORY_FOR = User + class User(models.Model): + name = models.CharField(max_length=191) - username = 'user' - password = factory.PostGenerationMethodCall('set_password', - 'defaultpassword') + def register(self, system, auth_token="ABC"): + self.registration_id = system.register(auth_token) -When we instantiate a user from the ``UserFactory``, the factory -will create a password attribute by calling ``User.set_password('defaultpassword')``. -Thus, by default, our users will have a password set to ``'defaultpassword'``. -.. code-block:: pycon + class UserFactory(factory.django.DjangoModelFactory): + class Meta: + model = User - >>> u = UserFactory() # Calls user.set_password('defaultpassword') - >>> u.check_password('defaultpassword') - True + name = 'user' + register = factory.PostGenerationMethodCall("register", DefaultRegistry()) If the :class:`PostGenerationMethodCall` declaration contained no -arguments or one argument, an overriding the value can be passed +arguments or one argument, an overriding value can be passed directly to the method through a keyword argument matching the attribute name. -For example we can override the default password specified in the declaration -above by simply passing in the desired password as a keyword argument to the -factory during instantiation. .. code-block:: pycon - >>> other_u = UserFactory(password='different') # Calls user.set_password('different') - >>> other_u.check_password('defaultpassword') - False - >>> other_u.check_password('different') - True + >>> # DefaultRegistry uses UUID for identifiers. + >>> UserFactory().registration_id + 'edf42c11-0065-43ad-ad3d-78ab7497aaae' + >>> # OtherRegistry uses int for identifiers. + >>> UserFactory(register=OtherRegistry()).registration_id + 123456 -.. note:: - - For Django models, unless the object method called by - :class:`PostGenerationMethodCall` saves the object back to the - database, we will have to explicitly remember to save the object back - if we performed a ``create()``. - - .. code-block:: pycon - - >>> u = UserFactory.create() # u.password has not been saved back to the database - >>> u.save() # we must remember to do it ourselves - - - We can avoid this by subclassing from :class:`DjangoModelFactory`, - instead, e.g., - - .. code-block:: python - - class UserFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = User - - username = 'user' - password = factory.PostGenerationMethodCall('set_password', - 'defaultpassword') - - -If instead the :class:`PostGenerationMethodCall` declaration uses two or -more positional arguments, the overriding value must be an iterable. For -example, if we declared the ``password`` attribute like the following, - -.. code-block:: python - - class UserFactory(factory.Factory): - FACTORY_FOR = User - - username = 'user' - password = factory.PostGenerationMethodCall('set_password', '', 'sha1') - -then we must be cautious to pass in an iterable for the ``password`` -keyword argument when creating an instance from the factory: - -.. code-block:: pycon - - >>> UserFactory() # Calls user.set_password('', 'sha1') - >>> UserFactory(password=('test', 'md5')) # Calls user.set_password('test', 'md5') - - >>> # Always pass in a good iterable: - >>> UserFactory(password=('test',)) # Calls user.set_password('test') - >>> UserFactory(password='test') # Calls user.set_password('t', 'e', 's', 't') - - -.. note:: While this setup provides sane and intuitive defaults for most users, - it prevents passing more than one argument when the declaration used - zero or one. - - In such cases, users are advised to either resort to the more powerful - :class:`PostGeneration` or to add the second expected argument default - value to the :class:`PostGenerationMethodCall` declaration - (``PostGenerationMethodCall('method', 'x', 'y_that_is_the_default')``) +.. warning:: In order to keep a consistent and simple API, a :class:`PostGenerationMethodCall` + allows *at most one* positional argument; all other parameters should be passed as + keyword arguments. Keywords extracted from the factory arguments are merged into the defaults present in the :class:`PostGenerationMethodCall` declaration. .. code-block:: pycon - >>> UserFactory(password__disabled=True) # Calls user.set_password('', 'sha1', disabled=True) - + >>> # Calls user.register(DefaultRegistry(), auth_token="DEF") + >>> UserFactory(register__auth_token="DEF") Module-level functions ---------------------- @@ -1467,7 +2007,8 @@ Lightweight factory declaration # This is equivalent to: class UserFactory(factory.Factory): - FACTORY_FOR = User + class Meta: + model = User login = 'john' email = factory.LazyAttribute(lambda u: '%s@example.com' % u.login) @@ -1486,7 +2027,8 @@ Lightweight factory declaration # This is equivalent to: class UserFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.User + class Meta: + model = models.User login = 'john' email = factory.LazyAttribute(lambda u: '%s@example.com' % u.login) @@ -1499,16 +2041,26 @@ Instance building """"""""""""""""" The :mod:`factory` module provides a bunch of shortcuts for creating a factory and -extracting instances from them: +extracting instances from them. Helper methods can be used to create factories +in a dynamic way based on parameters. + +Internally, helper methods use :func:`make_factory` to create a new +:class:`Factory` and perform additional calls on the newly created +:class:`Factory` according to the method name. + +Please note, that all Factories created with this methods inherit from the +:class:`factory.Factory` class. For full support of your ``ORM``, specify +a base class with the ``FACTORY_CLASS`` parameter as shown in +:func:`make_factory` examples. .. function:: build(klass, FACTORY_CLASS=None, **kwargs) .. function:: build_batch(klass, size, FACTORY_CLASS=None, **kwargs) - Create a factory for :obj:`klass` using declarations passed in kwargs; - return an instance built from that factory, - or a list of :obj:`size` instances (for :func:`build_batch`). + Create a factory for ``klass`` using declarations passed in kwargs; + return an instance built from that factory with :data:`BUILD_STRATEGY`, + or a list of ``size`` instances (for :func:`build_batch`). - :param class klass: Class of the instance to build + :param type klass: Class of the instance to build :param int size: Number of instances to build :param kwargs: Declarations to use for the generated factory :param FACTORY_CLASS: Alternate base class (instead of :class:`Factory`) @@ -1518,11 +2070,11 @@ extracting instances from them: .. function:: create(klass, FACTORY_CLASS=None, **kwargs) .. function:: create_batch(klass, size, FACTORY_CLASS=None, **kwargs) - Create a factory for :obj:`klass` using declarations passed in kwargs; - return an instance created from that factory, - or a list of :obj:`size` instances (for :func:`create_batch`). + Create a factory for ``klass`` using declarations passed in kwargs; + return an instance created from that factory with :data:`CREATE_STRATEGY`, + or a list of ``size`` instances (for :func:`create_batch`). - :param class klass: Class of the instance to create + :param type klass: Class of the instance to create :param int size: Number of instances to create :param kwargs: Declarations to use for the generated factory :param FACTORY_CLASS: Alternate base class (instead of :class:`Factory`) @@ -1532,11 +2084,11 @@ extracting instances from them: .. function:: stub(klass, FACTORY_CLASS=None, **kwargs) .. function:: stub_batch(klass, size, FACTORY_CLASS=None, **kwargs) - Create a factory for :obj:`klass` using declarations passed in kwargs; - return an instance stubbed from that factory, - or a list of :obj:`size` instances (for :func:`stub_batch`). + Create a factory for ``klass`` using declarations passed in kwargs; + return an instance stubbed from that factory with :data:`STUB_STRATEGY`, + or a list of ``size`` instances (for :func:`stub_batch`). - :param class klass: Class of the instance to stub + :param type klass: Class of the instance to stub :param int size: Number of instances to stub :param kwargs: Declarations to use for the generated factory :param FACTORY_CLASS: Alternate base class (instead of :class:`Factory`) @@ -1546,11 +2098,11 @@ extracting instances from them: .. function:: generate(klass, strategy, FACTORY_CLASS=None, **kwargs) .. function:: generate_batch(klass, strategy, size, FACTORY_CLASS=None, **kwargs) - Create a factory for :obj:`klass` using declarations passed in kwargs; - return an instance generated from that factory with the :obj:`strategy` strategy, - or a list of :obj:`size` instances (for :func:`generate_batch`). + Create a factory for ``klass`` using declarations passed in kwargs; + return an instance generated from that factory with the ``strategy`` strategy, + or a list of ``size`` instances (for :func:`generate_batch`). - :param class klass: Class of the instance to generate + :param type klass: Class of the instance to generate :param str strategy: The strategy to use :param int size: Number of instances to generate :param kwargs: Declarations to use for the generated factory @@ -1561,14 +2113,47 @@ extracting instances from them: .. function:: simple_generate(klass, create, FACTORY_CLASS=None, **kwargs) .. function:: simple_generate_batch(klass, create, size, FACTORY_CLASS=None, **kwargs) - Create a factory for :obj:`klass` using declarations passed in kwargs; - return an instance generated from that factory according to the :obj:`create` flag, - or a list of :obj:`size` instances (for :func:`simple_generate_batch`). + Create a factory for ``klass`` using declarations passed in kwargs; + return an instance generated from that factory according to the ``create`` flag, + or a list of ``size`` instances (for :func:`simple_generate_batch`). - :param class klass: Class of the instance to generate + :param type klass: Class of the instance to generate :param bool create: Whether to build (``False``) or create (``True``) instances :param int size: Number of instances to generate :param kwargs: Declarations to use for the generated factory :param FACTORY_CLASS: Alternate base class (instead of :class:`Factory`) +Randomness management +--------------------- + +.. module:: factory.random + +Using :mod:`random` in factories allows to "fuzz" a program efficiently. +However, it's sometimes required to *reproduce* a failing test. + +:mod:`factory.fuzzy` and :class:`factory.Faker` share a dedicated instance +of :class:`random.Random`, which can be managed through the :mod:`factory.random` module: + +.. method:: get_random_state() + + Call :meth:`get_random_state` to retrieve the random generator's current + state. This method synchronizes both Faker’s and factory_boy’s random state. + The returned object is implementation-specific. + +.. method:: set_random_state(state) + + Use :meth:`set_random_state` to set a custom state into the random generator + (fetched from :meth:`get_random_state` in a previous run, for instance) + +.. method:: reseed_random(seed) + + The :meth:`reseed_random` function allows to load a chosen seed into the random generator. + That seed can be anything accepted by :func:`random.seed`. + +.. data:: randgen + + The :class:`random.Random` global instance used by :mod:`factory.fuzzy` + and :class:`factory.Faker`. + +See :ref:`recipe-random-management` for help in using those methods in a test setup. diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt new file mode 100644 index 00000000..1c62c8cd --- /dev/null +++ b/docs/spelling_wordlist.txt @@ -0,0 +1,37 @@ +args +backends +backtrace +boolean +datastore +datetimes +dicts +Django +filename +fuzzer +fuzzers +fuzzying +getter +instantiation +iterable +iterables +kwarg +kwargs +metaclass +misconfiguration +Mogo +MongoDB +mongoengine +pre +prepend +pymongo +queryset +recurse +subclassed +subclasses +subclassing +subfactories +thoughtbot +tox +unexplicit +username +lookup diff --git a/examples/Makefile b/examples/Makefile new file mode 100644 index 00000000..c99a25a4 --- /dev/null +++ b/examples/Makefile @@ -0,0 +1,9 @@ +EXAMPLES = django_demo flask_alchemy + +TEST_TARGETS = $(addprefix runtest-,$(EXAMPLES)) + +test: $(TEST_TARGETS) + + +$(TEST_TARGETS): runtest-%: + cd $* && ./runtests.sh diff --git a/examples/django_demo/django_demo/__init__.py b/examples/django_demo/django_demo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/django_demo/django_demo/settings.py b/examples/django_demo/django_demo/settings.py new file mode 100644 index 00000000..5135faf9 --- /dev/null +++ b/examples/django_demo/django_demo/settings.py @@ -0,0 +1,121 @@ +""" +Django settings for django_demo project. + +Generated by 'django-admin startproject' using Django 1.10. + +For more information on this file, see +https://docs.djangoproject.com/en/1.10/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.10/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'kh)1s3@93ju6f6$qx!758f6h^(_3d0brqzoxubo@xsn3*%2wgu' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'generic_foreignkey' +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'django_demo.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'django_demo.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.10/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.10/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.10/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/examples/django_demo/django_demo/urls.py b/examples/django_demo/django_demo/urls.py new file mode 100644 index 00000000..a8b95abb --- /dev/null +++ b/examples/django_demo/django_demo/urls.py @@ -0,0 +1,21 @@ +"""django_demo URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.10/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/examples/django_demo/django_demo/wsgi.py b/examples/django_demo/django_demo/wsgi.py new file mode 100644 index 00000000..3a5bea10 --- /dev/null +++ b/examples/django_demo/django_demo/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for django_demo project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_demo.settings") + +application = get_wsgi_application() diff --git a/examples/django_demo/generic_foreignkey/__init__.py b/examples/django_demo/generic_foreignkey/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/django_demo/generic_foreignkey/apps.py b/examples/django_demo/generic_foreignkey/apps.py new file mode 100644 index 00000000..4ca07241 --- /dev/null +++ b/examples/django_demo/generic_foreignkey/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class GenericForeignKeyConfig(AppConfig): + name = 'generic_foreignkey' diff --git a/examples/django_demo/generic_foreignkey/factories.py b/examples/django_demo/generic_foreignkey/factories.py new file mode 100644 index 00000000..c7f5117e --- /dev/null +++ b/examples/django_demo/generic_foreignkey/factories.py @@ -0,0 +1,44 @@ +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType + +import factory.django + +from .models import TaggedItem + + +class UserFactory(factory.django.DjangoModelFactory): + first_name = 'Adam' + + class Meta: + model = User + + +class GroupFactory(factory.django.DjangoModelFactory): + name = 'group' + + class Meta: + model = Group + + +class TaggedItemFactory(factory.django.DjangoModelFactory): + object_id = factory.SelfAttribute('content_object.id') + content_type = factory.LazyAttribute( + lambda o: ContentType.objects.get_for_model(o.content_object)) + + class Meta: + exclude = ['content_object'] + abstract = True + + +class TaggedUserFactory(TaggedItemFactory): + content_object = factory.SubFactory(UserFactory) + + class Meta: + model = TaggedItem + + +class TaggedGroupFactory(TaggedItemFactory): + content_object = factory.SubFactory(GroupFactory) + + class Meta: + model = TaggedItem diff --git a/examples/django_demo/generic_foreignkey/migrations/0001_initial.py b/examples/django_demo/generic_foreignkey/migrations/0001_initial.py new file mode 100644 index 00000000..ce5cf761 --- /dev/null +++ b/examples/django_demo/generic_foreignkey/migrations/0001_initial.py @@ -0,0 +1,29 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='TaggedItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tag', models.SlugField()), + ('object_id', models.PositiveIntegerField()), + ( + 'content_type', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='contenttypes.ContentType' + ) + ), + ], + ), + ] diff --git a/examples/django_demo/generic_foreignkey/migrations/__init__.py b/examples/django_demo/generic_foreignkey/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/django_demo/generic_foreignkey/models.py b/examples/django_demo/generic_foreignkey/models.py new file mode 100644 index 00000000..912a8d1f --- /dev/null +++ b/examples/django_demo/generic_foreignkey/models.py @@ -0,0 +1,14 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + + +class TaggedItem(models.Model): + """Example GenericForeignKey model from django docs""" + tag = models.SlugField() + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + + def __str__(self): + return self.tag diff --git a/examples/django_demo/generic_foreignkey/tests.py b/examples/django_demo/generic_foreignkey/tests.py new file mode 100644 index 00000000..c1950a24 --- /dev/null +++ b/examples/django_demo/generic_foreignkey/tests.py @@ -0,0 +1,28 @@ +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from .factories import GroupFactory, TaggedGroupFactory, TaggedUserFactory, UserFactory + + +class GenericFactoryTest(TestCase): + + def test_user_factory(self): + user = UserFactory() + self.assertEqual(user.first_name, 'Adam') + + def test_group_factory(self): + group = GroupFactory() + self.assertEqual(group.name, 'group') + + def test_generic_user(self): + model = TaggedUserFactory(tag='user') + self.assertEqual(model.tag, 'user') + self.assertTrue(isinstance(model.content_object, User)) + self.assertEqual(model.content_type, ContentType.objects.get_for_model(model.content_object)) + + def test_generic_group(self): + model = TaggedGroupFactory(tag='group') + self.assertEqual(model.tag, 'group') + self.assertTrue(isinstance(model.content_object, Group)) + self.assertEqual(model.content_type, ContentType.objects.get_for_model(model.content_object)) diff --git a/examples/django_demo/manage.py b/examples/django_demo/manage.py new file mode 100755 index 00000000..2aa7a57e --- /dev/null +++ b/examples/django_demo/manage.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_demo.settings") + from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) diff --git a/examples/django_demo/requirements.txt b/examples/django_demo/requirements.txt new file mode 100644 index 00000000..94a0e834 --- /dev/null +++ b/examples/django_demo/requirements.txt @@ -0,0 +1 @@ +Django diff --git a/examples/django_demo/runtests.sh b/examples/django_demo/runtests.sh new file mode 100755 index 00000000..0fd70cdb --- /dev/null +++ b/examples/django_demo/runtests.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +cd $(dirname $0) +python manage.py test; diff --git a/examples/flask_alchemy/demoapp.py b/examples/flask_alchemy/demoapp.py new file mode 100644 index 00000000..f8e762ff --- /dev/null +++ b/examples/flask_alchemy/demoapp.py @@ -0,0 +1,36 @@ +# Copyright: See the LICENSE file. + +from flask import Flask +from flask_sqlalchemy import SQLAlchemy + +app = Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db' +db = SQLAlchemy(app) + + +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True) + email = db.Column(db.String(120), unique=True) + + def __init__(self, username, email): + self.username = username + self.email = email + + def __repr__(self): + return '' % self.username + + +class UserLog(db.Model): + id = db.Column(db.Integer, primary_key=True) + message = db.Column(db.String(1000)) + + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + user = db.relationship('User', backref=db.backref('logs', lazy='dynamic')) + + def __init__(self, message, user): + self.message = message + self.user = user + + def __repr__(self): + return f'' diff --git a/examples/flask_alchemy/demoapp_factories.py b/examples/flask_alchemy/demoapp_factories.py new file mode 100644 index 00000000..35472425 --- /dev/null +++ b/examples/flask_alchemy/demoapp_factories.py @@ -0,0 +1,26 @@ +import demoapp + +import factory.alchemy +import factory.fuzzy + + +class BaseFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + abstract = True + sqlalchemy_session = demoapp.db.session + + +class UserFactory(BaseFactory): + class Meta: + model = demoapp.User + + username = factory.fuzzy.FuzzyText() + email = factory.fuzzy.FuzzyText() + + +class UserLogFactory(BaseFactory): + class Meta: + model = demoapp.UserLog + + message = factory.fuzzy.FuzzyText() + user = factory.SubFactory(UserFactory) diff --git a/examples/flask_alchemy/requirements.txt b/examples/flask_alchemy/requirements.txt new file mode 100644 index 00000000..fb675a95 --- /dev/null +++ b/examples/flask_alchemy/requirements.txt @@ -0,0 +1,2 @@ +Flask +Flask-SQLAlchemy diff --git a/examples/flask_alchemy/runtests.sh b/examples/flask_alchemy/runtests.sh new file mode 100755 index 00000000..24fedf2d --- /dev/null +++ b/examples/flask_alchemy/runtests.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +cd $(dirname $0) +for f in test_*.py; do + python -m unittest discover +done diff --git a/examples/flask_alchemy/test_demoapp.py b/examples/flask_alchemy/test_demoapp.py new file mode 100644 index 00000000..68d56935 --- /dev/null +++ b/examples/flask_alchemy/test_demoapp.py @@ -0,0 +1,36 @@ +import unittest + +import demoapp +import demoapp_factories + + +class DemoAppTestCase(unittest.TestCase): + + def setUp(self): + demoapp.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' + demoapp.app.config['TESTING'] = True + + self.app_context = demoapp.app.app_context() + self.app_context.push() + + self.app = demoapp.app.test_client() + self.db = demoapp.db + self.db.create_all() + + def tearDown(self): + self.db.drop_all() + self.app_context.pop() + + def test_user_factory(self): + user = demoapp_factories.UserFactory() + self.db.session.commit() + self.assertIsNotNone(user.id) + self.assertEqual(1, len(demoapp.User.query.all())) + + def test_userlog_factory(self): + userlog = demoapp_factories.UserLogFactory() + self.db.session.commit() + self.assertIsNotNone(userlog.id) + self.assertIsNotNone(userlog.user.id) + self.assertEqual(1, len(demoapp.User.query.all())) + self.assertEqual(1, len(demoapp.UserLog.query.all())) diff --git a/examples/requirements.txt b/examples/requirements.txt new file mode 100644 index 00000000..ee2e75a0 --- /dev/null +++ b/examples/requirements.txt @@ -0,0 +1,2 @@ +-r flask_alchemy/requirements.txt +-r django_demo/requirements.txt diff --git a/factory/__init__.py b/factory/__init__.py index c40274fc..62042a2a 100644 --- a/factory/__init__.py +++ b/factory/__init__.py @@ -1,83 +1,75 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -__version__ = '2.2.1' -__author__ = 'Raphaël Barrois ' +# Copyright: See the LICENSE file. +import importlib.metadata from .base import ( - Factory, BaseDictFactory, - DictFactory, BaseListFactory, + DictFactory, + Factory, ListFactory, StubFactory, - - BUILD_STRATEGY, - CREATE_STRATEGY, - STUB_STRATEGY, use_strategy, ) - -# Backward compatibility; this should be removed soon. -from .mogo import MogoFactory -from .django import DjangoModelFactory - from .declarations import ( - LazyAttribute, - Iterator, - Sequence, - LazyAttributeSequence, - SelfAttribute, ContainerAttribute, - SubFactory, Dict, + Iterator, + LazyAttribute, + LazyAttributeSequence, + LazyFunction, List, + Maybe, PostGeneration, PostGenerationMethodCall, RelatedFactory, + RelatedFactoryList, + SelfAttribute, + Sequence, + SubFactory, + Trait, + Transformer, ) - +from .enums import BUILD_STRATEGY, CREATE_STRATEGY, STUB_STRATEGY +from .errors import FactoryError +from .faker import Faker from .helpers import ( - debug, - build, - create, - stub, - generate, - simple_generate, - make_factory, - build_batch, + container_attribute, + create, create_batch, - stub_batch, + debug, + generate, generate_batch, - simple_generate_batch, - - lazy_attribute, iterator, - sequence, + lazy_attribute, lazy_attribute_sequence, - container_attribute, + make_factory, post_generation, + sequence, + simple_generate, + simple_generate_batch, + stub, + stub_batch, ) +try: + from . import alchemy +except ImportError: + pass +try: + from . import django +except ImportError: + pass +try: + from . import mogo +except ImportError: + pass +try: + from . import mongoengine +except ImportError: + pass + +__author__ = 'Raphaël Barrois ' +__version__ = importlib.metadata.version("factory_boy") diff --git a/factory/alchemy.py b/factory/alchemy.py index cec15c92..e782fbd8 100644 --- a/factory/alchemy.py +++ b/factory/alchemy.py @@ -1,51 +1,128 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2013 Romain Commandé -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -from __future__ import unicode_literals -from sqlalchemy.sql.functions import max - -from . import base +# Copyright: See the LICENSE file. + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm.exc import NoResultFound + +from . import base, errors + +SESSION_PERSISTENCE_COMMIT = 'commit' +SESSION_PERSISTENCE_FLUSH = 'flush' +VALID_SESSION_PERSISTENCE_TYPES = [ + None, + SESSION_PERSISTENCE_COMMIT, + SESSION_PERSISTENCE_FLUSH, +] + + +class SQLAlchemyOptions(base.FactoryOptions): + def _check_sqlalchemy_session_persistence(self, meta, value): + if value not in VALID_SESSION_PERSISTENCE_TYPES: + raise TypeError( + "%s.sqlalchemy_session_persistence must be one of %s, got %r" % + (meta, VALID_SESSION_PERSISTENCE_TYPES, value) + ) + + @staticmethod + def _check_has_sqlalchemy_session_set(meta, value): + if value is not None and getattr(meta, "sqlalchemy_session", None) is not None: + raise RuntimeError("Provide either a sqlalchemy_session or a sqlalchemy_session_factory, not both") + + def _build_default_options(self): + return super()._build_default_options() + [ + base.OptionDefault('sqlalchemy_get_or_create', (), inherit=True), + base.OptionDefault('sqlalchemy_session', None, inherit=True), + base.OptionDefault( + 'sqlalchemy_session_factory', None, inherit=True, checker=self._check_has_sqlalchemy_session_set + ), + base.OptionDefault( + 'sqlalchemy_session_persistence', + None, + inherit=True, + checker=self._check_sqlalchemy_session_persistence, + ), + ] class SQLAlchemyModelFactory(base.Factory): """Factory for SQLAlchemy models. """ - ABSTRACT_FACTORY = True - FACTORY_SESSION = None + _options_class = SQLAlchemyOptions + _original_params = None + + class Meta: + abstract = True @classmethod - def _setup_next_sequence(cls, *args, **kwargs): - """Compute the next available PK, based on the 'pk' database field.""" - session = cls.FACTORY_SESSION - model = cls.FACTORY_FOR - pk = getattr(model, model.__mapper__.primary_key[0].name) - max_pk = session.query(max(pk)).one()[0] - if isinstance(max_pk, int): - return max_pk + 1 if max_pk else 1 - else: - return 1 + def _generate(cls, strategy, params): + # Original params are used in _get_or_create if it cannot build an + # object initially due to an IntegrityError being raised + cls._original_params = params + return super()._generate(strategy, params) @classmethod - def _create(cls, target_class, *args, **kwargs): + def _get_or_create(cls, model_class, session, args, kwargs): + key_fields = {} + for field in cls._meta.sqlalchemy_get_or_create: + if field not in kwargs: + raise errors.FactoryError( + "sqlalchemy_get_or_create - " + "Unable to find initialization value for '%s' in factory %s" % + (field, cls.__name__)) + key_fields[field] = kwargs.pop(field) + + obj = session.query(model_class).filter_by( + *args, **key_fields).one_or_none() + + if not obj: + try: + obj = cls._save(model_class, session, args, {**key_fields, **kwargs}) + except IntegrityError as e: + session.rollback() + + if cls._original_params is None: + raise e + + get_or_create_params = { + lookup: value + for lookup, value in cls._original_params.items() + if lookup in cls._meta.sqlalchemy_get_or_create + } + if get_or_create_params: + try: + obj = session.query(model_class).filter_by( + **get_or_create_params).one() + except NoResultFound: + # Original params are not a valid lookup and triggered a create(), + # that resulted in an IntegrityError. + raise e + else: + raise e + + return obj + + @classmethod + def _create(cls, model_class, *args, **kwargs): """Create an instance of the model, and save it to the database.""" - session = cls.FACTORY_SESSION - obj = target_class(*args, **kwargs) + session_factory = cls._meta.sqlalchemy_session_factory + if session_factory: + cls._meta.sqlalchemy_session = session_factory() + + session = cls._meta.sqlalchemy_session + + if session is None: + raise RuntimeError("No session provided.") + if cls._meta.sqlalchemy_get_or_create: + return cls._get_or_create(model_class, session, args, kwargs) + return cls._save(model_class, session, args, kwargs) + + @classmethod + def _save(cls, model_class, session, args, kwargs): + session_persistence = cls._meta.sqlalchemy_session_persistence + + obj = model_class(*args, **kwargs) session.add(obj) + if session_persistence == SESSION_PERSISTENCE_FLUSH: + session.flush() + elif session_persistence == SESSION_PERSISTENCE_COMMIT: + session.commit() return obj diff --git a/factory/base.py b/factory/base.py index 81836492..454513be 100644 --- a/factory/base.py +++ b/factory/base.py @@ -1,71 +1,33 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. + +import collections import logging +import warnings +from typing import Generic, List, Type, TypeVar -from . import containers -from . import utils +from . import builder, declarations, enums, errors, utils logger = logging.getLogger('factory.generate') -# Strategies -BUILD_STRATEGY = 'build' -CREATE_STRATEGY = 'create' -STUB_STRATEGY = 'stub' - - -# Special declarations -FACTORY_CLASS_DECLARATION = 'FACTORY_FOR' - -# Factory class attributes -CLASS_ATTRIBUTE_DECLARATIONS = '_declarations' -CLASS_ATTRIBUTE_POSTGEN_DECLARATIONS = '_postgen_declarations' -CLASS_ATTRIBUTE_ASSOCIATED_CLASS = '_associated_class' -CLASS_ATTRIBUTE_IS_ABSTRACT = '_abstract_factory' - - -class FactoryError(Exception): - """Any exception raised by factory_boy.""" - - -class AssociatedClassError(FactoryError): - """Exception for Factory subclasses lacking FACTORY_FOR.""" - - -class UnknownStrategy(FactoryError): - """Raised when a factory uses an unknown strategy.""" - - -class UnsupportedStrategy(FactoryError): - """Raised when trying to use a strategy on an incompatible Factory.""" - +T = TypeVar('T') # Factory metaclasses + def get_factory_bases(bases): """Retrieve all FactoryMetaClass-derived bases from a list.""" return [b for b in bases if issubclass(b, BaseFactory)] +def resolve_attribute(name, bases, default=None): + """Find the first definition of an attribute according to MRO order.""" + for base in bases: + if hasattr(base, name): + return getattr(base, name) + return default + + class FactoryMetaClass(type): """Factory metaclass for handling ordered declarations.""" @@ -75,146 +37,358 @@ def __call__(cls, **kwargs): Returns an instance of the associated class. """ - if cls.FACTORY_STRATEGY == BUILD_STRATEGY: + if cls._meta.strategy == enums.BUILD_STRATEGY: return cls.build(**kwargs) - elif cls.FACTORY_STRATEGY == CREATE_STRATEGY: + elif cls._meta.strategy == enums.CREATE_STRATEGY: return cls.create(**kwargs) - elif cls.FACTORY_STRATEGY == STUB_STRATEGY: + elif cls._meta.strategy == enums.STUB_STRATEGY: return cls.stub(**kwargs) else: - raise UnknownStrategy('Unknown FACTORY_STRATEGY: {0}'.format( - cls.FACTORY_STRATEGY)) + raise errors.UnknownStrategy('Unknown Meta.strategy: {}'.format( + cls._meta.strategy)) - @classmethod - def _discover_associated_class(mcs, class_name, attrs, inherited=None): - """Try to find the class associated with this factory. + def __new__(mcs, class_name, bases, attrs): + """Record attributes as a pattern for later instance construction. - In order, the following tests will be performed: - - Lookup the FACTORY_CLASS_DECLARATION attribute - - If an inherited associated class was provided, use it. + This is called when a new Factory subclass is defined; it will collect + attribute declaration from the class definition. Args: - class_name (str): the name of the factory class being created - attrs (dict): the dict of attributes from the factory class + class_name (str): the name of the class being created + bases (list of class): the parents of the class being created + attrs (str => obj dict): the attributes as defined in the class definition - inherited (class): the optional associated class inherited from a - parent factory Returns: - class: the class to associate with this factory + A new class """ - if FACTORY_CLASS_DECLARATION in attrs: - return attrs[FACTORY_CLASS_DECLARATION] + parent_factories = get_factory_bases(bases) + if parent_factories: + base_factory = parent_factories[0] + else: + base_factory = None - # No specific associated class was given, and one was defined for our - # parent, use it. - if inherited is not None: - return inherited + attrs_meta = attrs.pop('Meta', None) + attrs_params = attrs.pop('Params', None) - # Nothing found, return None. - return None + base_meta = resolve_attribute('_meta', bases) + options_class = resolve_attribute('_options_class', bases, FactoryOptions) - @classmethod - def _extract_declarations(mcs, bases, attributes): - """Extract declarations from a class definition. + meta = options_class() + attrs['_meta'] = meta - Args: - bases (class list): parent Factory subclasses - attributes (dict): attributes declared in the class definition + new_class = super().__new__( + mcs, class_name, bases, attrs) - Returns: - dict: the original attributes, where declarations have been moved to - _declarations and post-generation declarations to - _postgen_declarations. - """ - declarations = containers.DeclarationDict() - postgen_declarations = containers.PostGenerationDeclarationDict() + meta.contribute_to_class( + new_class, + meta=attrs_meta, + base_meta=base_meta, + base_factory=base_factory, + params=attrs_params, + ) - # Add parent declarations in reverse order. - for base in reversed(bases): - # Import parent PostGenerationDeclaration - postgen_declarations.update_with_public( - getattr(base, CLASS_ATTRIBUTE_POSTGEN_DECLARATIONS, {})) - # Import all 'public' attributes (avoid those starting with _) - declarations.update_with_public( - getattr(base, CLASS_ATTRIBUTE_DECLARATIONS, {})) + return new_class - # Import attributes from the class definition - attributes = postgen_declarations.update_with_public(attributes) - # Store protected/private attributes in 'non_factory_attrs'. - attributes = declarations.update_with_public(attributes) + def __str__(cls): + if cls._meta.abstract: + return '<%s (abstract)>' % cls.__name__ + else: + return f'<{cls.__name__} for {cls._meta.model}>' - # Store the DeclarationDict in the attributes of the newly created class - attributes[CLASS_ATTRIBUTE_DECLARATIONS] = declarations - attributes[CLASS_ATTRIBUTE_POSTGEN_DECLARATIONS] = postgen_declarations - return attributes +class BaseMeta: + abstract = True + strategy = enums.CREATE_STRATEGY - def __new__(mcs, class_name, bases, attrs): - """Record attributes as a pattern for later instance construction. - This is called when a new Factory subclass is defined; it will collect - attribute declaration from the class definition. +class OptionDefault: + """The default for an option. - Args: - class_name (str): the name of the class being created - bases (list of class): the parents of the class being created - attrs (str => obj dict): the attributes as defined in the class - definition + Attributes: + name: str, the name of the option ('class Meta' attribute) + value: object, the default value for the option + inherit: bool, whether to inherit the value from the parent factory's `class Meta` + when no value is provided + checker: callable or None, an optional function used to detect invalid option + values at declaration time + """ + def __init__(self, name, value, inherit=False, checker=None): + self.name = name + self.value = value + self.inherit = inherit + self.checker = checker + + def apply(self, meta, base_meta): + value = self.value + if self.inherit and base_meta is not None: + value = getattr(base_meta, self.name, value) + if meta is not None: + value = getattr(meta, self.name, value) + + if self.checker is not None: + self.checker(meta, value) - Returns: - A new class + return value + + def __str__(self): + return '%s(%r, %r, inherit=%r)' % ( + self.__class__.__name__, + self.name, self.value, self.inherit) + + +class FactoryOptions: + def __init__(self): + self.factory = None + self.base_factory = None + self.base_declarations = {} + self.parameters = {} + self.parameters_dependencies = {} + self.pre_declarations = builder.DeclarationSet() + self.post_declarations = builder.DeclarationSet() + + self._counter = None + self.counter_reference = None + + @property + def declarations(self): + base_declarations = dict(self.base_declarations) + for name, param in utils.sort_ordered_objects(self.parameters.items(), getter=lambda item: item[1]): + base_declarations.update(param.as_declarations(name, base_declarations)) + return base_declarations + + def _build_default_options(self): + """"Provide the default value for all allowed fields. + + Custom FactoryOptions classes should override this method + to update() its return value. """ - parent_factories = get_factory_bases(bases) - if not parent_factories: - return super(FactoryMetaClass, mcs).__new__( - mcs, class_name, bases, attrs) - extra_attrs = {} + def is_model(meta, value): + if isinstance(value, FactoryMetaClass): + raise TypeError( + "%s is already a %s" + % (repr(value), Factory.__name__) + ) - is_abstract = attrs.pop('ABSTRACT_FACTORY', False) + return [ + OptionDefault('model', None, inherit=True, checker=is_model), + OptionDefault('abstract', False, inherit=False), + OptionDefault('strategy', enums.CREATE_STRATEGY, inherit=True), + OptionDefault('inline_args', (), inherit=True), + OptionDefault('exclude', (), inherit=True), + OptionDefault('rename', {}, inherit=True), + ] + + def _fill_from_meta(self, meta, base_meta): + # Exclude private/protected fields from the meta + if meta is None: + meta_attrs = {} + else: + meta_attrs = { + k: v + for (k, v) in vars(meta).items() + if not k.startswith('_') + } + + for option in self._build_default_options(): + assert not hasattr(self, option.name), "Can't override field %s." % option.name + value = option.apply(meta, base_meta) + meta_attrs.pop(option.name, None) + setattr(self, option.name, value) + + if meta_attrs: + # Some attributes in the Meta aren't allowed here + raise TypeError( + "'class Meta' for %r got unknown attribute(s) %s" + % (self.factory, ','.join(sorted(meta_attrs.keys())))) + + def contribute_to_class(self, factory, meta=None, base_meta=None, base_factory=None, params=None): + + self.factory = factory + self.base_factory = base_factory + + self._fill_from_meta(meta=meta, base_meta=base_meta) + + self.model = self.get_model_class() + if self.model is None: + self.abstract = True + + self.counter_reference = self._get_counter_reference() + + # Scan the inheritance chain, starting from the furthest point, + # excluding the current class, to retrieve all declarations. + for parent in reversed(self.factory.__mro__[1:]): + if not hasattr(parent, '_meta'): + continue + self.base_declarations.update(parent._meta.base_declarations) + self.parameters.update(parent._meta.parameters) + + for k, v in vars(self.factory).items(): + if self._is_declaration(k, v): + self.base_declarations[k] = v + + if params is not None: + for k, v in utils.sort_ordered_objects(vars(params).items(), getter=lambda item: item[1]): + if not k.startswith('_'): + self.parameters[k] = declarations.SimpleParameter.wrap(v) + + self._check_parameter_dependencies(self.parameters) + + self.pre_declarations, self.post_declarations = builder.parse_declarations(self.declarations) + + def _get_counter_reference(self): + """Identify which factory should be used for a shared counter.""" + + if (self.model is not None + and self.base_factory is not None + and self.base_factory._meta.model is not None + and issubclass(self.model, self.base_factory._meta.model)): + return self.base_factory._meta.counter_reference + else: + return self - base = parent_factories[0] - inherited_associated_class = getattr(base, - CLASS_ATTRIBUTE_ASSOCIATED_CLASS, None) - associated_class = mcs._discover_associated_class(class_name, attrs, - inherited_associated_class) + def _initialize_counter(self): + """Initialize our counter pointer. - if associated_class is None: - is_abstract = True + If we're the top-level factory, instantiate a new counter + Otherwise, point to the top-level factory's counter. + """ + if self._counter is not None: + return + if self.counter_reference is self: + self._counter = _Counter(seq=self.factory._setup_next_sequence()) else: - # If inheriting the factory from a parent, keep a link to it. - # This allows to use the sequence counters from the parents. - if (inherited_associated_class is not None - and issubclass(associated_class, inherited_associated_class)): - attrs['_base_factory'] = base + self.counter_reference._initialize_counter() + self._counter = self.counter_reference._counter - # The CLASS_ATTRIBUTE_ASSOCIATED_CLASS must *not* be taken into - # account when parsing the declared attributes of the new class. - extra_attrs[CLASS_ATTRIBUTE_ASSOCIATED_CLASS] = associated_class + def next_sequence(self): + """Retrieve a new sequence ID. - extra_attrs[CLASS_ATTRIBUTE_IS_ABSTRACT] = is_abstract + This will call, in order: + - next_sequence from the base factory, if provided + - _setup_next_sequence, if this is the 'toplevel' factory and the + sequence counter wasn't initialized yet; then increase it. + """ + self._initialize_counter() + return self._counter.next() - # Extract pre- and post-generation declarations - attributes = mcs._extract_declarations(parent_factories, attrs) - attributes.update(extra_attrs) + def reset_sequence(self, value=None, force=False): + self._initialize_counter() - return super(FactoryMetaClass, mcs).__new__( - mcs, class_name, bases, attributes) + if self.counter_reference is not self and not force: + raise ValueError( + "Can't reset a sequence on descendant factory %r; reset sequence on %r or use `force=True`." + % (self.factory, self.counter_reference.factory)) + + if value is None: + value = self.counter_reference.factory._setup_next_sequence() + self._counter.reset(value) + + def prepare_arguments(self, attributes): + """Convert an attributes dict to a (args, kwargs) tuple.""" + kwargs = dict(attributes) + # 1. Extension points + kwargs = self.factory._adjust_kwargs(**kwargs) + + # 2. Remove hidden objects + kwargs = { + k: v for k, v in kwargs.items() + if k not in self.exclude and k not in self.parameters and v is not declarations.SKIP + } + + # 3. Rename fields + for old_name, new_name in self.rename.items(): + if old_name in kwargs: + kwargs[new_name] = kwargs.pop(old_name) + + # 4. Extract inline args + args = tuple( + kwargs.pop(arg_name) + for arg_name in self.inline_args + ) - def __str__(cls): - if cls._abstract_factory: - return '<%s (abstract)>' + return args, kwargs + + def instantiate(self, step, args, kwargs): + model = self.get_model_class() + + if step.builder.strategy == enums.BUILD_STRATEGY: + return self.factory._build(model, *args, **kwargs) + elif step.builder.strategy == enums.CREATE_STRATEGY: + return self.factory._create(model, *args, **kwargs) else: - return '<%s for %s>' % (cls.__name__, - getattr(cls, CLASS_ATTRIBUTE_ASSOCIATED_CLASS).__name__) + assert step.builder.strategy == enums.STUB_STRATEGY + return StubObject(**kwargs) + + def use_postgeneration_results(self, step, instance, results): + self.factory._after_postgeneration( + instance, + create=step.builder.strategy == enums.CREATE_STRATEGY, + results=results, + ) + + def _is_declaration(self, name, value): + """Determines if a class attribute is a field value declaration. + + Based on the name and value of the class attribute, return ``True`` if + it looks like a declaration of a default field value, ``False`` if it + is private (name starts with '_') or a classmethod or staticmethod. + + """ + if isinstance(value, (classmethod, staticmethod)): + return False + elif enums.get_builder_phase(value): + # All objects with a defined 'builder phase' are declarations. + return True + return not name.startswith("_") + + def _check_parameter_dependencies(self, parameters): + """Find out in what order parameters should be called.""" + # Warning: parameters only provide reverse dependencies; we reverse them into standard dependencies. + # deep_revdeps: set of fields a field depend indirectly upon + deep_revdeps = collections.defaultdict(set) + # Actual, direct dependencies + deps = collections.defaultdict(set) + + for name, parameter in parameters.items(): + if isinstance(parameter, declarations.Parameter): + field_revdeps = parameter.get_revdeps(parameters) + if not field_revdeps: + continue + deep_revdeps[name] = set.union(*(deep_revdeps[dep] for dep in field_revdeps)) + deep_revdeps[name] |= set(field_revdeps) + for dep in field_revdeps: + deps[dep].add(name) + + # Check for cyclical dependencies + cyclic = [name for name, field_deps in deep_revdeps.items() if name in field_deps] + if cyclic: + raise errors.CyclicDefinitionError( + "Cyclic definition detected on %r; Params around %s" + % (self.factory, ', '.join(cyclic))) + return deps + + def get_model_class(self): + """Extension point for loading model classes. + + This can be overridden in framework-specific subclasses to hook into + existing model repositories, for instance. + """ + return self.model + + def __str__(self): + return "<%s for %s>" % (self.__class__.__name__, self.factory.__name__) + + def __repr__(self): + return str(self) # Factory base classes -class _Counter(object): +class _Counter: """Simple, naive counter. Attributes: @@ -222,9 +396,8 @@ class _Counter(object): seq (int): the next value """ - def __init__(self, seq, for_class): + def __init__(self, seq): self.seq = seq - self.for_class = for_class def next(self): value = self.seq @@ -234,42 +407,23 @@ def next(self): def reset(self, next_value=0): self.seq = next_value - def __repr__(self): - return '<_Counter for %s.%s, next=%d>' % ( - self.for_class.__module__, self.for_class.__name__, self.seq) - -class BaseFactory(object): +class BaseFactory(Generic[T]): """Factory base support for sequences, attributes and stubs.""" # Backwards compatibility - UnknownStrategy = UnknownStrategy - UnsupportedStrategy = UnsupportedStrategy + UnknownStrategy = errors.UnknownStrategy + UnsupportedStrategy = errors.UnsupportedStrategy def __new__(cls, *args, **kwargs): """Would be called if trying to instantiate the class.""" - raise FactoryError('You cannot instantiate BaseFactory') + raise errors.FactoryError('You cannot instantiate BaseFactory') + + _meta = FactoryOptions() # ID to use for the next 'declarations.Sequence' attribute. _counter = None - # Base factory, if this class was inherited from another factory. This is - # used for sharing the sequence _counter among factories for the same - # class. - _base_factory = None - - # Holds the target class, once resolved. - _associated_class = None - - # Whether this factory is considered "abstract", thus uncallable. - _abstract_factory = False - - # List of arguments that should be passed as *args instead of **kwargs - FACTORY_ARG_PARAMETERS = () - - # List of attributes that should not be passed to the underlying class - FACTORY_HIDDEN_ARGS = () - @classmethod def reset_sequence(cls, value=None, force=False): """Reset the sequence counter. @@ -280,20 +434,7 @@ def reset_sequence(cls, value=None, force=False): force (bool): whether to force-reset parent sequence counters in a factory inheritance chain. """ - if cls._base_factory: - if force: - cls._base_factory.reset_sequence(value=value) - else: - raise ValueError( - "Cannot reset the sequence of a factory subclass. " - "Please call reset_sequence() on the root factory, " - "or call reset_sequence(force=True)." - ) - else: - cls._setup_counter() - if value is None: - value = cls._setup_next_sequence() - cls._counter.reset(value) + cls._meta.reset_sequence(value, force=force) @classmethod def _setup_next_sequence(cls): @@ -304,201 +445,82 @@ def _setup_next_sequence(cls): """ return 0 - @classmethod - def _setup_counter(cls): - """Ensures cls._counter is set for this class. - - Due to the way inheritance works in Python, we need to ensure that the - ``_counter`` attribute has been initialized for *this* Factory subclass, - not one of its parents. - """ - if cls._counter is None or cls._counter.for_class != cls: - first_seq = cls._setup_next_sequence() - cls._counter = _Counter(for_class=cls, seq=first_seq) - logger.debug("%r: Setting up next sequence (%d)", cls, first_seq) - - @classmethod - def _generate_next_sequence(cls): - """Retrieve a new sequence ID. - - This will call, in order: - - _generate_next_sequence from the base factory, if provided - - _setup_next_sequence, if this is the 'toplevel' factory and the - sequence counter wasn't initialized yet; then increase it. - """ - - # Rely upon our parents - if cls._base_factory and not cls._base_factory._abstract_factory: - logger.debug("%r: reusing sequence from %r", cls, cls._base_factory) - return cls._base_factory._generate_next_sequence() - - # Make sure _counter is initialized - cls._setup_counter() - - # Pick current value, then increase class counter for the next call. - return cls._counter.next() - - @classmethod - def attributes(cls, create=False, extra=None): - """Build a dict of attribute values, respecting declaration order. - - The process is: - - Handle 'orderless' attributes, overriding defaults with provided - kwargs when applicable - - Handle ordered attributes, overriding them with provided kwargs when - applicable; the current list of computed attributes is available - to the currently processed object. - """ - force_sequence = None - if extra: - force_sequence = extra.pop('__sequence', None) - log_ctx = '%s.%s' % (cls.__module__, cls.__name__) - logger.debug('BaseFactory: Preparing %s.%s(extra=%r)', - cls.__module__, - cls.__name__, - extra, - ) - return containers.AttributeBuilder(cls, extra, log_ctx=log_ctx).build( - create=create, - force_sequence=force_sequence, - ) - - @classmethod - def declarations(cls, extra_defs=None): - """Retrieve a copy of the declared attributes. - - Args: - extra_defs (dict): additional definitions to insert into the - retrieved DeclarationDict. - """ - return getattr(cls, CLASS_ATTRIBUTE_DECLARATIONS).copy(extra_defs) - @classmethod def _adjust_kwargs(cls, **kwargs): """Extension point for custom kwargs adjustment.""" return kwargs @classmethod - def _load_target_class(cls): - """Extension point for loading target classes. - - This can be overridden in framework-specific subclasses to hook into - existing model repositories, for instance. - """ - return getattr(cls, CLASS_ATTRIBUTE_ASSOCIATED_CLASS) - - @classmethod - def _prepare(cls, create, **kwargs): - """Prepare an object for this factory. - - Args: - create: bool, whether to create or to build the object - **kwargs: arguments to pass to the creation function - """ - target_class = cls._load_target_class() - kwargs = cls._adjust_kwargs(**kwargs) - - # Remove 'hidden' arguments. - for arg in cls.FACTORY_HIDDEN_ARGS: - del kwargs[arg] - - # Extract *args from **kwargs - args = tuple(kwargs.pop(key) for key in cls.FACTORY_ARG_PARAMETERS) - - logger.debug('BaseFactory: Generating %s.%s(%s)', - cls.__module__, - cls.__name__, - utils.log_pprint(args, kwargs), - ) - if create: - return cls._create(target_class, *args, **kwargs) - else: - return cls._build(target_class, *args, **kwargs) - - @classmethod - def _generate(cls, create, attrs): + def _generate(cls, strategy, params): """generate the object. Args: - create (bool): whether to 'build' or 'create' the object - attrs (dict): attributes to use for generating the object + params (dict): attributes to use for generating the object + strategy: the strategy to use """ - if cls._abstract_factory: - raise FactoryError( + if cls._meta.abstract: + raise errors.FactoryError( "Cannot generate instances of abstract factory %(f)s; " - "Ensure %(f)s.FACTORY_FOR is set and %(f)s.ABSTRACT_FACTORY " - "is either not set or False." % dict(f=cls)) - - # Extract declarations used for post-generation - postgen_declarations = getattr(cls, - CLASS_ATTRIBUTE_POSTGEN_DECLARATIONS) - postgen_attributes = {} - for name, decl in sorted(postgen_declarations.items()): - postgen_attributes[name] = decl.extract(name, attrs) - - # Generate the object - obj = cls._prepare(create, **attrs) + "Ensure %(f)s.Meta.model is set and %(f)s.Meta.abstract " + "is either not set or False." % dict(f=cls.__name__)) - # Handle post-generation attributes - results = {} - for name, decl in sorted(postgen_declarations.items()): - extraction_context = postgen_attributes[name] - results[name] = decl.call(obj, create, extraction_context) - - cls._after_postgeneration(obj, create, results) - - return obj + step = builder.StepBuilder(cls._meta, params, strategy) + return step.build() @classmethod - def _after_postgeneration(cls, obj, create, results=None): + def _after_postgeneration(cls, instance, create, results=None): """Hook called after post-generation declarations have been handled. Args: - obj (object): the generated object + instance (object): the generated object create (bool): whether the strategy was 'build' or 'create' results (dict or None): result of post-generation declarations """ pass @classmethod - def _build(cls, target_class, *args, **kwargs): - """Actually build an instance of the target_class. + def _build(cls, model_class, *args, **kwargs): + """Actually build an instance of the model_class. Customization point, will be called once the full set of args and kwargs has been computed. Args: - target_class (type): the class for which an instance should be + model_class (type): the class for which an instance should be built args (tuple): arguments to use when building the class kwargs (dict): keyword arguments to use when building the class """ - return target_class(*args, **kwargs) + return model_class(*args, **kwargs) @classmethod - def _create(cls, target_class, *args, **kwargs): - """Actually create an instance of the target_class. + def _create(cls, model_class, *args, **kwargs): + """Actually create an instance of the model_class. Customization point, will be called once the full set of args and kwargs has been computed. Args: - target_class (type): the class for which an instance should be + model_class (type): the class for which an instance should be created args (tuple): arguments to use when creating the class kwargs (dict): keyword arguments to use when creating the class """ - return target_class(*args, **kwargs) + return model_class(*args, **kwargs) @classmethod - def build(cls, **kwargs): - """Build an instance of the associated class, with overriden attrs.""" - attrs = cls.attributes(create=False, extra=kwargs) - return cls._generate(False, attrs) + def build(cls, **kwargs) -> T: + """Build an instance of the associated class, with overridden attrs. + + The instance will not be saved and persisted to any datastore. + """ + return cls._generate(enums.BUILD_STRATEGY, kwargs) @classmethod - def build_batch(cls, size, **kwargs): - """Build a batch of instances of the given class, with overriden attrs. + def build_batch(cls, size: int, **kwargs) -> List[T]: + """Build a batch of instances of the given class, with overridden attrs. + + The instances will not be saved and persisted to any datastore. Args: size (int): the number of instances to build @@ -509,14 +531,18 @@ def build_batch(cls, size, **kwargs): return [cls.build(**kwargs) for _ in range(size)] @classmethod - def create(cls, **kwargs): - """Create an instance of the associated class, with overriden attrs.""" - attrs = cls.attributes(create=True, extra=kwargs) - return cls._generate(True, attrs) + def create(cls, **kwargs) -> T: + """Create an instance of the associated class, with overridden attrs. + + The instance will be saved and persisted in the appropriate datastore. + """ + return cls._generate(enums.CREATE_STRATEGY, kwargs) @classmethod - def create_batch(cls, size, **kwargs): - """Create a batch of instances of the given class, with overriden attrs. + def create_batch(cls, size: int, **kwargs) -> List[T]: + """Create a batch of instances of the given class, with overridden attrs. + + The instances will be saved and persisted in the appropriate datastore. Args: size (int): the number of instances to create @@ -528,19 +554,16 @@ def create_batch(cls, size, **kwargs): @classmethod def stub(cls, **kwargs): - """Retrieve a stub of the associated class, with overriden attrs. + """Retrieve a stub of the associated class, with overridden attrs. This will return an object whose attributes are those defined in this factory's declarations or in the extra kwargs. """ - stub_object = containers.StubObject() - for name, value in cls.attributes(create=False, extra=kwargs).items(): - setattr(stub_object, name, value) - return stub_object + return cls._generate(enums.STUB_STRATEGY, kwargs) @classmethod def stub_batch(cls, size, **kwargs): - """Stub a batch of instances of the given class, with overriden attrs. + """Stub a batch of instances of the given class, with overridden attrs. Args: size (int): the number of instances to stub @@ -563,7 +586,7 @@ def generate(cls, strategy, **kwargs): Returns: object: the generated instance """ - assert strategy in (STUB_STRATEGY, BUILD_STRATEGY, CREATE_STRATEGY) + assert strategy in (enums.STUB_STRATEGY, enums.BUILD_STRATEGY, enums.CREATE_STRATEGY) action = getattr(cls, strategy) return action(**kwargs) @@ -581,7 +604,7 @@ def generate_batch(cls, strategy, size, **kwargs): Returns: object list: the generated instances """ - assert strategy in (STUB_STRATEGY, BUILD_STRATEGY, CREATE_STRATEGY) + assert strategy in (enums.STUB_STRATEGY, enums.BUILD_STRATEGY, enums.CREATE_STRATEGY) batch_action = getattr(cls, '%s_batch' % strategy) return batch_action(size, **kwargs) @@ -597,7 +620,7 @@ def simple_generate(cls, create, **kwargs): Returns: object: the generated instance """ - strategy = CREATE_STRATEGY if create else BUILD_STRATEGY + strategy = enums.CREATE_STRATEGY if create else enums.BUILD_STRATEGY return cls.generate(strategy, **kwargs) @classmethod @@ -613,79 +636,97 @@ def simple_generate_batch(cls, create, size, **kwargs): Returns: object list: the generated instances """ - strategy = CREATE_STRATEGY if create else BUILD_STRATEGY + strategy = enums.CREATE_STRATEGY if create else enums.BUILD_STRATEGY return cls.generate_batch(strategy, size, **kwargs) -Factory = FactoryMetaClass('Factory', (BaseFactory,), { - 'ABSTRACT_FACTORY': True, - 'FACTORY_STRATEGY': CREATE_STRATEGY, - '__doc__': """Factory base with build and create support. +class Factory(BaseFactory[T], metaclass=FactoryMetaClass): + """Factory base with build and create support. This class has the ability to support multiple ORMs by using custom creation functions. - """, -}) + """ + # Backwards compatibility + AssociatedClassError: Type[Exception] + + class Meta(BaseMeta): + pass -# Backwards compatibility -Factory.AssociatedClassError = AssociatedClassError # pylint: disable=W0201 + +# Add the association after metaclass execution. +# Otherwise, AssociatedClassError would be detected as a declaration. +Factory.AssociatedClassError = errors.AssociatedClassError + + +class StubObject: + """A generic container.""" + def __init__(self, **kwargs): + for field, value in kwargs.items(): + setattr(self, field, value) class StubFactory(Factory): - FACTORY_STRATEGY = STUB_STRATEGY - FACTORY_FOR = containers.StubObject + class Meta: + strategy = enums.STUB_STRATEGY + model = StubObject @classmethod def build(cls, **kwargs): - raise UnsupportedStrategy() + return cls.stub(**kwargs) @classmethod def create(cls, **kwargs): - raise UnsupportedStrategy() + raise errors.UnsupportedStrategy() class BaseDictFactory(Factory): """Factory for dictionary-like classes.""" - ABSTRACT_FACTORY = True + class Meta: + abstract = True @classmethod - def _build(cls, target_class, *args, **kwargs): + def _build(cls, model_class, *args, **kwargs): if args: raise ValueError( - "DictFactory %r does not support FACTORY_ARG_PARAMETERS.", cls) - return target_class(**kwargs) + "DictFactory %r does not support Meta.inline_args." % cls) + return model_class(**kwargs) @classmethod - def _create(cls, target_class, *args, **kwargs): - return cls._build(target_class, *args, **kwargs) + def _create(cls, model_class, *args, **kwargs): + return cls._build(model_class, *args, **kwargs) class DictFactory(BaseDictFactory): - FACTORY_FOR = dict + class Meta: + model = dict class BaseListFactory(Factory): """Factory for list-like classes.""" - ABSTRACT_FACTORY = True + class Meta: + abstract = True @classmethod - def _build(cls, target_class, *args, **kwargs): + def _build(cls, model_class, *args, **kwargs): if args: raise ValueError( - "ListFactory %r does not support FACTORY_ARG_PARAMETERS.", cls) + "ListFactory %r does not support Meta.inline_args." % cls) - values = [v for k, v in sorted(kwargs.items())] - return target_class(values) + # kwargs are constructed from a list, their insertion order matches the list + # order, no additional sorting is required. + values = kwargs.values() + return model_class(values) @classmethod - def _create(cls, target_class, *args, **kwargs): - return cls._build(target_class, *args, **kwargs) + def _create(cls, model_class, *args, **kwargs): + return cls._build(model_class, *args, **kwargs) class ListFactory(BaseListFactory): - FACTORY_FOR = list + class Meta: + model = list def use_strategy(new_strategy): @@ -693,7 +734,13 @@ def use_strategy(new_strategy): This is an alternative to setting default_strategy in the class definition. """ + warnings.warn( + "use_strategy() is deprecated and will be removed in the future.", + DeprecationWarning, + stacklevel=2, + ) + def wrapped_class(klass): - klass.FACTORY_STRATEGY = new_strategy + klass._meta.strategy = new_strategy return klass return wrapped_class diff --git a/factory/builder.py b/factory/builder.py new file mode 100644 index 00000000..e76e7556 --- /dev/null +++ b/factory/builder.py @@ -0,0 +1,377 @@ +"""Build factory instances.""" + +import collections + +from . import enums, errors, utils + +DeclarationWithContext = collections.namedtuple( + 'DeclarationWithContext', + ['name', 'declaration', 'context'], +) + + +class DeclarationSet: + """A set of declarations, including the recursive parameters. + + Attributes: + declarations (dict(name => declaration)): the top-level declarations + contexts (dict(name => dict(subfield => value))): the nested parameters related + to a given top-level declaration + + This object behaves similarly to a dict mapping a top-level declaration name to a + DeclarationWithContext, containing field name, declaration object and extra context. + """ + + def __init__(self, initial=None): + self.declarations = {} + self.contexts = collections.defaultdict(dict) + self.update(initial or {}) + + @classmethod + def split(cls, entry): + """Split a declaration name into a (declaration, subpath) tuple. + + Examples: + >>> DeclarationSet.split('foo__bar') + ('foo', 'bar') + >>> DeclarationSet.split('foo') + ('foo', None) + >>> DeclarationSet.split('foo__bar__baz') + ('foo', 'bar__baz') + """ + if enums.SPLITTER in entry: + return entry.split(enums.SPLITTER, 1) + else: + return (entry, None) + + @classmethod + def join(cls, root, subkey): + """Rebuild a full declaration name from its components. + + for every string x, we have `join(split(x)) == x`. + """ + if subkey is None: + return root + return enums.SPLITTER.join((root, subkey)) + + def copy(self): + return self.__class__(self.as_dict()) + + def update(self, values): + """Add new declarations to this set/ + + Args: + values (dict(name, declaration)): the declarations to ingest. + """ + for k, v in values.items(): + root, sub = self.split(k) + if sub is None: + self.declarations[root] = v + else: + self.contexts[root][sub] = v + + extra_context_keys = set(self.contexts) - set(self.declarations) + if extra_context_keys: + raise errors.InvalidDeclarationError( + "Received deep context for unknown fields: %r (known=%r)" % ( + { + self.join(root, sub): v + for root in extra_context_keys + for sub, v in self.contexts[root].items() + }, + sorted(self.declarations), + ) + ) + + def filter(self, entries): + """Filter a set of declarations: keep only those related to this object. + + This will keep: + - Declarations that 'override' the current ones + - Declarations that are parameters to current ones + """ + return [ + entry for entry in entries + if self.split(entry)[0] in self.declarations + ] + + def sorted(self): + return utils.sort_ordered_objects( + self.declarations, + getter=lambda entry: self.declarations[entry], + ) + + def __contains__(self, key): + return key in self.declarations + + def __getitem__(self, key): + return DeclarationWithContext( + name=key, + declaration=self.declarations[key], + context=self.contexts[key], + ) + + def __iter__(self): + return iter(self.declarations) + + def values(self): + """Retrieve the list of declarations, with their context.""" + for name in self: + yield self[name] + + def _items(self): + """Extract a list of (key, value) pairs, suitable for our __init__.""" + for name in self.declarations: + yield name, self.declarations[name] + for subkey, value in self.contexts[name].items(): + yield self.join(name, subkey), value + + def as_dict(self): + """Return a dict() suitable for our __init__.""" + return dict(self._items()) + + def __repr__(self): + return '' % self.as_dict() + + +def _captures_overrides(declaration_with_context): + declaration = declaration_with_context.declaration + if enums.get_builder_phase(declaration) == enums.BuilderPhase.ATTRIBUTE_RESOLUTION: + return declaration.CAPTURE_OVERRIDES + else: + return False + + +def parse_declarations(decls, base_pre=None, base_post=None): + pre_declarations = base_pre.copy() if base_pre else DeclarationSet() + post_declarations = base_post.copy() if base_post else DeclarationSet() + + # Inject extra declarations, splitting between known-to-be-post and undetermined + extra_post = {} + extra_maybenonpost = {} + for k, v in decls.items(): + if enums.get_builder_phase(v) == enums.BuilderPhase.POST_INSTANTIATION: + if k in pre_declarations: + # Conflict: PostGenerationDeclaration with the same + # name as a BaseDeclaration + raise errors.InvalidDeclarationError( + "PostGenerationDeclaration %s=%r shadows declaration %r" + % (k, v, pre_declarations[k]) + ) + extra_post[k] = v + elif k in post_declarations: + # Passing in a scalar value to a PostGenerationDeclaration + # Set it as `key__` + magic_key = post_declarations.join(k, '') + extra_post[magic_key] = v + else: + extra_maybenonpost[k] = v + + # Start with adding new post-declarations + post_declarations.update(extra_post) + + # Fill in extra post-declaration context + extra_pre_declarations = {} + extra_post_declarations = {} + post_overrides = post_declarations.filter(extra_maybenonpost) + for k, v in extra_maybenonpost.items(): + if k in post_overrides: + extra_post_declarations[k] = v + elif k in pre_declarations and _captures_overrides(pre_declarations[k]): + # Send the overriding value to the existing declaration. + # By symmetry with the behaviour of PostGenerationDeclaration, + # we send it as `key__` -- i.e under the '' key. + magic_key = pre_declarations.join(k, '') + extra_pre_declarations[magic_key] = v + else: + # Anything else is pre_declarations + extra_pre_declarations[k] = v + pre_declarations.update(extra_pre_declarations) + post_declarations.update(extra_post_declarations) + + return pre_declarations, post_declarations + + +class BuildStep: + def __init__(self, builder, sequence, parent_step=None): + self.builder = builder + self.sequence = sequence + self.attributes = {} + self.parent_step = parent_step + self.stub = None + + def resolve(self, declarations): + self.stub = Resolver( + declarations=declarations, + step=self, + sequence=self.sequence, + ) + + for field_name in declarations: + self.attributes[field_name] = getattr(self.stub, field_name) + + @property + def chain(self): + if self.parent_step: + parent_chain = self.parent_step.chain + else: + parent_chain = () + return (self.stub,) + parent_chain + + def recurse(self, factory, declarations, force_sequence=None): + from . import base + if not issubclass(factory, base.BaseFactory): + raise errors.AssociatedClassError( + "%r: Attempting to recursing into a non-factory object %r" + % (self, factory)) + builder = self.builder.recurse(factory._meta, declarations) + return builder.build(parent_step=self, force_sequence=force_sequence) + + def __repr__(self): + return f"" + + +class StepBuilder: + """A factory instantiation step. + + Attributes: + - parent: the parent StepBuilder, or None for the root step + - extras: the passed-in kwargs for this branch + - factory: the factory class being built + - strategy: the strategy to use + """ + def __init__(self, factory_meta, extras, strategy): + self.factory_meta = factory_meta + self.strategy = strategy + self.extras = extras + self.force_init_sequence = extras.pop('__sequence', None) + + def build(self, parent_step=None, force_sequence=None): + """Build a factory instance.""" + # TODO: Handle "batch build" natively + pre, post = parse_declarations( + self.extras, + base_pre=self.factory_meta.pre_declarations, + base_post=self.factory_meta.post_declarations, + ) + + if force_sequence is not None: + sequence = force_sequence + elif self.force_init_sequence is not None: + sequence = self.force_init_sequence + else: + sequence = self.factory_meta.next_sequence() + + step = BuildStep( + builder=self, + sequence=sequence, + parent_step=parent_step, + ) + step.resolve(pre) + + args, kwargs = self.factory_meta.prepare_arguments(step.attributes) + + instance = self.factory_meta.instantiate( + step=step, + args=args, + kwargs=kwargs, + ) + + postgen_results = {} + for declaration_name in post.sorted(): + declaration = post[declaration_name] + postgen_results[declaration_name] = declaration.declaration.evaluate_post( + instance=instance, + step=step, + overrides=declaration.context, + ) + self.factory_meta.use_postgeneration_results( + instance=instance, + step=step, + results=postgen_results, + ) + return instance + + def recurse(self, factory_meta, extras): + """Recurse into a sub-factory call.""" + return self.__class__(factory_meta, extras, strategy=self.strategy) + + def __repr__(self): + return f"" + + +class Resolver: + """Resolve a set of declarations. + + Attributes are set at instantiation time, values are computed lazily. + + Attributes: + __initialized (bool): whether this object's __init__ as run. If set, + setting any attribute will be prevented. + __declarations (dict): maps attribute name to their declaration + __values (dict): maps attribute name to computed value + __pending (str list): names of the attributes whose value is being + computed. This allows to detect cyclic lazy attribute definition. + __step (BuildStep): the BuildStep related to this resolver. + This allows to have the value of a field depend on the value of + another field + """ + + __initialized = False + + def __init__(self, declarations, step, sequence): + self.__declarations = declarations + self.__step = step + + self.__values = {} + self.__pending = [] + + self.__initialized = True + + @property + def factory_parent(self): + return self.__step.parent_step.stub if self.__step.parent_step else None + + def __repr__(self): + return '' % self.__step + + def __getattr__(self, name): + """Retrieve an attribute's value. + + This will compute it if needed, unless it is already on the list of + attributes being computed. + """ + if name in self.__pending: + raise errors.CyclicDefinitionError( + "Cyclic lazy attribute definition for %r; cycle found in %r." % + (name, self.__pending)) + elif name in self.__values: + return self.__values[name] + elif name in self.__declarations: + declaration = self.__declarations[name] + value = declaration.declaration + if enums.get_builder_phase(value) == enums.BuilderPhase.ATTRIBUTE_RESOLUTION: + self.__pending.append(name) + try: + value = value.evaluate_pre( + instance=self, + step=self.__step, + overrides=declaration.context, + ) + finally: + last = self.__pending.pop() + assert name == last + + self.__values[name] = value + return value + else: + raise AttributeError( + "The parameter %r is unknown. Evaluated attributes are %r, " + "definitions are %r." % (name, self.__values, self.__declarations)) + + def __setattr__(self, name, value): + """Prevent setting attributes once __init__ is done.""" + if not self.__initialized: + return super().__setattr__(name, value) + else: + raise AttributeError('Setting of object attributes is not allowed') diff --git a/factory/compat.py b/factory/compat.py deleted file mode 100644 index 7747b1a6..00000000 --- a/factory/compat.py +++ /dev/null @@ -1,78 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - - -"""Compatibility tools""" - -import datetime -import decimal -import sys - -PY2 = (sys.version_info[0] == 2) - -if PY2: # pragma: no cover - def is_string(obj): - return isinstance(obj, (str, unicode)) - - from StringIO import StringIO as BytesIO - -else: # pragma: no cover - def is_string(obj): - return isinstance(obj, str) - - from io import BytesIO - - -if sys.version_info[:2] == (2, 6): # pragma: no cover - def float_to_decimal(fl): - return decimal.Decimal(str(fl)) -else: # pragma: no cover - def float_to_decimal(fl): - return decimal.Decimal(fl) - - -try: # pragma: no cover - # Python >= 3.2 - UTC = datetime.timezone.utc -except AttributeError: # pragma: no cover - try: - # Fallback to pytz - from pytz import UTC - except ImportError: - - # Ok, let's write our own. - class _UTC(datetime.tzinfo): - """The UTC tzinfo.""" - - def utcoffset(self, dt): - return datetime.timedelta(0) - - def tzname(self, dt): - return "UTC" - - def dst(self, dt): - return datetime.timedelta(0) - - def localize(self, dt): - dt.astimezone(self) - - UTC = _UTC() diff --git a/factory/containers.py b/factory/containers.py deleted file mode 100644 index 7a4c5db3..00000000 --- a/factory/containers.py +++ /dev/null @@ -1,287 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -import logging - -logger = logging.getLogger(__name__) - -from . import declarations -from . import utils - - -class CyclicDefinitionError(Exception): - """Raised when cyclic definition were found.""" - - -class LazyStub(object): - """A generic container that only allows getting attributes. - - Attributes are set at instantiation time, values are computed lazily. - - Attributes: - __initialized (bool): whether this object's __init__ as run. If set, - setting any attribute will be prevented. - __attrs (dict): maps attribute name to their declaration - __values (dict): maps attribute name to computed value - __pending (str list): names of the attributes whose value is being - computed. This allows to detect cyclic lazy attribute definition. - __containers (LazyStub list): "parents" of the LazyStub being built. - This allows to have the field of a field depend on the value of - another field - __target_class (type): the target class to build. - """ - - __initialized = False - - def __init__(self, attrs, containers=(), target_class=object, log_ctx=None): - self.__attrs = attrs - self.__values = {} - self.__pending = [] - self.__containers = containers - self.__target_class = target_class - self.__log_ctx = log_ctx or '%s.%s' % (target_class.__module__, target_class.__name__) - self.factory_parent = containers[0] if containers else None - self.__initialized = True - - def __repr__(self): - return '' % (self.__target_class.__module__, self.__target_class.__name__) - - def __str__(self): - return '' % ( - self.__target_class.__name__, list(self.__attrs.keys())) - - def __fill__(self): - """Fill this LazyStub, computing values of all defined attributes. - - Retunrs: - dict: map of attribute name => computed value - """ - res = {} - logger.debug("LazyStub: Computing values for %s(%s)", - self.__log_ctx, utils.log_pprint(kwargs=self.__attrs), - ) - for attr in self.__attrs: - res[attr] = getattr(self, attr) - logger.debug("LazyStub: Computed values, got %s(%s)", - self.__log_ctx, utils.log_pprint(kwargs=res), - ) - return res - - def __getattr__(self, name): - """Retrieve an attribute's value. - - This will compute it if needed, unless it is already on the list of - attributes being computed. - """ - if name in self.__pending: - raise CyclicDefinitionError( - "Cyclic lazy attribute definition for %s; cycle found in %r." % - (name, self.__pending)) - elif name in self.__values: - return self.__values[name] - elif name in self.__attrs: - val = self.__attrs[name] - if isinstance(val, LazyValue): - self.__pending.append(name) - val = val.evaluate(self, self.__containers) - assert name == self.__pending.pop() - self.__values[name] = val - return val - else: - raise AttributeError( - "The parameter %s is unknown. Evaluated attributes are %r, " - "definitions are %r." % (name, self.__values, self.__attrs)) - - - def __setattr__(self, name, value): - """Prevent setting attributes once __init__ is done.""" - if not self.__initialized: - return super(LazyStub, self).__setattr__(name, value) - else: - raise AttributeError('Setting of object attributes is not allowed') - - -class DeclarationDict(dict): - """Slightly extended dict to work with OrderedDeclaration.""" - - def is_declaration(self, name, value): - """Determines if a class attribute is a field value declaration. - - Based on the name and value of the class attribute, return ``True`` if - it looks like a declaration of a default field value, ``False`` if it - is private (name starts with '_') or a classmethod or staticmethod. - - """ - if isinstance(value, (classmethod, staticmethod)): - return False - elif isinstance(value, declarations.OrderedDeclaration): - return True - return (not name.startswith("_") and not name.startswith("FACTORY_")) - - def update_with_public(self, d): - """Updates the DeclarationDict from a class definition dict. - - Takes into account all public attributes and OrderedDeclaration - instances; ignores all class/staticmethods and private attributes - (starting with '_'). - - Returns a dict containing all remaining elements. - """ - remaining = {} - for k, v in d.items(): - if self.is_declaration(k, v): - self[k] = v - else: - remaining[k] = v - return remaining - - def copy(self, extra=None): - """Copy this DeclarationDict into another one, including extra values. - - Args: - extra (dict): additional attributes to include in the copy. - """ - new = self.__class__() - new.update(self) - if extra: - new.update(extra) - return new - - -class PostGenerationDeclarationDict(DeclarationDict): - """Alternate DeclarationDict for PostGenerationDeclaration.""" - - def is_declaration(self, name, value): - """Captures instances of PostGenerationDeclaration.""" - return isinstance(value, declarations.PostGenerationDeclaration) - - -class LazyValue(object): - """Some kind of "lazy evaluating" object.""" - - def evaluate(self, obj, containers=()): # pragma: no cover - """Compute the value, using the given object.""" - raise NotImplementedError("This is an abstract method.") - - -class OrderedDeclarationWrapper(LazyValue): - """Lazy wrapper around an OrderedDeclaration. - - Attributes: - declaration (declarations.OrderedDeclaration): the OrderedDeclaration - being wrapped - sequence (int): the sequence counter to use when evaluatin the - declaration - """ - - def __init__(self, declaration, sequence, create, extra=None, **kwargs): - super(OrderedDeclarationWrapper, self).__init__(**kwargs) - self.declaration = declaration - self.sequence = sequence - self.create = create - self.extra = extra - - def evaluate(self, obj, containers=()): - """Lazily evaluate the attached OrderedDeclaration. - - Args: - obj (LazyStub): the object being built - containers (object list): the chain of containers of the object - being built, its immediate holder being first. - """ - return self.declaration.evaluate(self.sequence, obj, - create=self.create, - extra=self.extra, - containers=containers, - ) - - def __repr__(self): - return '<%s for %r>' % (self.__class__.__name__, self.declaration) - - -class AttributeBuilder(object): - """Builds attributes from a factory and extra data. - - Attributes: - factory (base.Factory): the Factory for which attributes are being - built - _attrs (DeclarationDict): the attribute declarations for the factory - _subfields (dict): dict mapping an attribute name to a dict of - overridden default values for the related SubFactory. - """ - - def __init__(self, factory, extra=None, log_ctx=None, **kwargs): - super(AttributeBuilder, self).__init__(**kwargs) - - if not extra: - extra = {} - - self.factory = factory - self._containers = extra.pop('__containers', ()) - self._attrs = factory.declarations(extra) - self._log_ctx = log_ctx - - initial_declarations = factory.declarations({}) - attrs_with_subfields = [ - k for k, v in initial_declarations.items() - if self.has_subfields(v)] - - self._subfields = utils.multi_extract_dict( - attrs_with_subfields, self._attrs) - - def has_subfields(self, value): - return isinstance(value, declarations.ParameteredAttribute) - - def build(self, create, force_sequence=None): - """Build a dictionary of attributes. - - Args: - create (bool): whether to 'build' or 'create' the subfactories. - force_sequence (int or None): if set to an int, use this value for - the sequence counter; don't advance the related counter. - """ - # Setup factory sequence. - if force_sequence is None: - sequence = self.factory._generate_next_sequence() - else: - sequence = force_sequence - - # Parse attribute declarations, wrapping SubFactory and - # OrderedDeclaration. - wrapped_attrs = {} - for k, v in self._attrs.items(): - if isinstance(v, declarations.OrderedDeclaration): - v = OrderedDeclarationWrapper(v, - sequence=sequence, - create=create, - extra=self._subfields.get(k, {}), - ) - wrapped_attrs[k] = v - - stub = LazyStub(wrapped_attrs, containers=self._containers, - target_class=self.factory, log_ctx=self._log_ctx) - return stub.__fill__() - - -class StubObject(object): - """A generic container.""" - pass diff --git a/factory/declarations.py b/factory/declarations.py index 037a6797..f835f0d2 100644 --- a/factory/declarations.py +++ b/factory/declarations.py @@ -1,81 +1,168 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. import itertools -import warnings import logging +import typing as T -from . import compat -from . import utils - +from . import enums, errors, utils logger = logging.getLogger('factory.generate') -class OrderedDeclaration(object): +class BaseDeclaration(utils.OrderedBase): """A factory declaration. - Ordered declarations mark an attribute as needing lazy evaluation. - This allows them to refer to attributes defined by other OrderedDeclarations + Declarations mark an attribute as needing lazy evaluation. + This allows them to refer to attributes defined by other BaseDeclarations in the same factory. """ - def evaluate(self, sequence, obj, create, extra=None, containers=()): + FACTORY_BUILDER_PHASE = enums.BuilderPhase.ATTRIBUTE_RESOLUTION + + #: Whether this declaration has a special handling for call-time overrides + #: (e.g. Tranformer). + #: Overridden values will be passed in the `extra` args. + CAPTURE_OVERRIDES = False + + #: Whether to unroll the context before evaluating the declaration. + #: Set to False on declarations that perform their own unrolling. + UNROLL_CONTEXT_BEFORE_EVALUATION = True + + def __init__(self, **defaults): + super().__init__() + self._defaults = defaults or {} + + def unroll_context(self, instance, step, context): + full_context = dict() + full_context.update(self._defaults) + full_context.update(context) + + if not self.UNROLL_CONTEXT_BEFORE_EVALUATION: + return full_context + if not any(enums.get_builder_phase(v) for v in full_context.values()): + # Optimization for simple contexts - don't do anything. + return full_context + + import factory.base + subfactory = factory.base.DictFactory + return step.recurse(subfactory, full_context, force_sequence=step.sequence) + + def _unwrap_evaluate_pre(self, wrapped, *, instance, step, overrides): + """Evaluate a wrapped pre-declaration. + + This is especially useful for declarations wrapping another one, + e.g. Maybe or Transformer. + """ + if isinstance(wrapped, BaseDeclaration): + return wrapped.evaluate_pre( + instance=instance, + step=step, + overrides=overrides, + ) + return wrapped + + def evaluate_pre(self, instance, step, overrides): + context = self.unroll_context(instance, step, overrides) + return self.evaluate(instance, step, context) + + def evaluate(self, instance, step, extra): """Evaluate this declaration. Args: - sequence (int): the current sequence counter to use when filling - the current instance - obj (containers.LazyStub): The object holding currently computed + instance (builder.Resolver): The object holding currently computed attributes - containers (list of containers.LazyStub): The chain of SubFactory - which led to building this object. - create (bool): whether the target class should be 'built' or - 'created' - extra (DeclarationDict or None): extracted key/value extracted from - the attribute prefix + step: a factory.builder.BuildStep + extra (dict): additional, call-time added kwargs + for the step. """ raise NotImplementedError('This is an abstract method') -class LazyAttribute(OrderedDeclaration): - """Specific OrderedDeclaration computed using a lambda. +class OrderedDeclaration(BaseDeclaration): + """Compatibility""" + + # FIXME(rbarrois) + + +class LazyFunction(BaseDeclaration): + """Simplest BaseDeclaration computed by calling the given function. + + Attributes: + function (function): a function without arguments and + returning the computed value. + """ + + def __init__(self, function): + super().__init__() + self.function = function + + def evaluate(self, instance, step, extra): + logger.debug("LazyFunction: Evaluating %r on %r", self.function, step) + return self.function() + + +class LazyAttribute(BaseDeclaration): + """Specific BaseDeclaration computed using a lambda. Attributes: function (function): a function, expecting the current LazyStub and returning the computed value. """ - def __init__(self, function, *args, **kwargs): - super(LazyAttribute, self).__init__(*args, **kwargs) + def __init__(self, function): + super().__init__() self.function = function - def evaluate(self, sequence, obj, create, extra=None, containers=()): - logger.debug("LazyAttribute: Evaluating %r on %r", self.function, obj) - return self.function(obj) + def evaluate(self, instance, step, extra): + logger.debug("LazyAttribute: Evaluating %r on %r", self.function, instance) + return self.function(instance) + +class Transformer(BaseDeclaration): + CAPTURE_OVERRIDES = True + UNROLL_CONTEXT_BEFORE_EVALUATION = False -class _UNSPECIFIED(object): + class Force: + """ + Bypass a transformer's transformation. + + The forced value can be any declaration, and will be evaluated as if it + had been passed instead of the Transformer declaration. + """ + def __init__(self, forced_value): + self.forced_value = forced_value + + def __repr__(self): + return f'Transformer.Force({repr(self.forced_value)})' + + def __init__(self, default, *, transform): + super().__init__() + self.default = default + self.transform = transform + + def evaluate_pre(self, instance, step, overrides): + # The call-time value, if present, is set under the "" key. + value_or_declaration = overrides.pop("", self.default) + + if isinstance(value_or_declaration, self.Force): + bypass_transform = True + value_or_declaration = value_or_declaration.forced_value + else: + bypass_transform = False + + value = self._unwrap_evaluate_pre( + value_or_declaration, + instance=instance, + step=step, + overrides=overrides, + ) + if bypass_transform: + return value + return self.transform(value) + + +class _UNSPECIFIED: pass @@ -108,8 +195,8 @@ def deepgetattr(obj, name, default=_UNSPECIFIED): return default -class SelfAttribute(OrderedDeclaration): - """Specific OrderedDeclaration copying values from other fields. +class SelfAttribute(BaseDeclaration): + """Specific BaseDeclaration copying values from other fields. If the field name starts with two dots or more, the lookup will be anchored in the related 'parent'. @@ -121,21 +208,21 @@ class SelfAttribute(OrderedDeclaration): exist. """ - def __init__(self, attribute_name, default=_UNSPECIFIED, *args, **kwargs): - super(SelfAttribute, self).__init__(*args, **kwargs) - depth = len(attribute_name) - len(attribute_name.lstrip('.')) + def __init__(self, attribute_name, default=_UNSPECIFIED): + super().__init__() + depth = len(attribute_name) - len(attribute_name.lstrip('.')) attribute_name = attribute_name[depth:] self.depth = depth self.attribute_name = attribute_name self.default = default - def evaluate(self, sequence, obj, create, extra=None, containers=()): + def evaluate(self, instance, step, extra): if self.depth > 1: # Fetching from a parent - target = containers[self.depth - 2] + target = step.chain[self.depth - 1] else: - target = obj + target = instance logger.debug("SelfAttribute: Picking attribute %r on %r", self.attribute_name, target) return deepgetattr(target, self.attribute_name, self.default) @@ -148,7 +235,7 @@ def __repr__(self): ) -class Iterator(OrderedDeclaration): +class Iterator(BaseDeclaration): """Fill this value using the values returned by an iterator. Warning: the iterator should not end ! @@ -159,14 +246,21 @@ class Iterator(OrderedDeclaration): """ def __init__(self, iterator, cycle=True, getter=None): - super(Iterator, self).__init__() + super().__init__() self.getter = getter + self.iterator = None if cycle: - iterator = itertools.cycle(iterator) - self.iterator = utils.ResetableIterator(iterator) + self.iterator_builder = lambda: utils.ResetableIterator(itertools.cycle(iterator)) + else: + self.iterator_builder = lambda: utils.ResetableIterator(iterator) + + def evaluate(self, instance, step, extra): + # Begin unrolling as late as possible. + # This helps with ResetableIterator(MyModel.objects.all()) + if self.iterator is None: + self.iterator = self.iterator_builder() - def evaluate(self, sequence, obj, create, extra=None, containers=()): logger.debug("Iterator: Fetching next value from %r", self.iterator) value = next(iter(self.iterator)) if self.getter is None: @@ -175,28 +269,26 @@ def evaluate(self, sequence, obj, create, extra=None, containers=()): def reset(self): """Reset the internal iterator.""" - self.iterator.reset() + if self.iterator is not None: + self.iterator.reset() -class Sequence(OrderedDeclaration): - """Specific OrderedDeclaration to use for 'sequenced' fields. +class Sequence(BaseDeclaration): + """Specific BaseDeclaration to use for 'sequenced' fields. These fields are typically used to generate increasing unique values. Attributes: function (function): A function, expecting the current sequence counter and returning the computed value. - type (function): A function converting an integer into the expected kind - of counter for the 'function' attribute. """ - def __init__(self, function, type=int): # pylint: disable=W0622 - super(Sequence, self).__init__() + def __init__(self, function): + super().__init__() self.function = function - self.type = type - def evaluate(self, sequence, obj, create, extra=None, containers=()): - logger.debug("Sequence: Computing next value of %r for seq=%d", self.function, sequence) - return self.function(self.type(sequence)) + def evaluate(self, instance, step, extra): + logger.debug("Sequence: Computing next value of %r for seq=%s", self.function, step.sequence) + return self.function(int(step.sequence)) class LazyAttributeSequence(Sequence): @@ -208,13 +300,14 @@ class LazyAttributeSequence(Sequence): type (function): A function converting an integer into the expected kind of counter for the 'function' attribute. """ - def evaluate(self, sequence, obj, create, extra=None, containers=()): - logger.debug("LazyAttributeSequence: Computing next value of %r for seq=%d, obj=%r", - self.function, sequence, obj) - return self.function(obj, self.type(sequence)) + def evaluate(self, instance, step, extra): + logger.debug( + "LazyAttributeSequence: Computing next value of %r for seq=%s, obj=%r", + self.function, step.sequence, instance) + return self.function(instance, int(step.sequence)) -class ContainerAttribute(OrderedDeclaration): +class ContainerAttribute(BaseDeclaration): """Variant of LazyAttribute, also receives the containers of the object. Attributes: @@ -223,12 +316,12 @@ class ContainerAttribute(OrderedDeclaration): strict (bool): Whether evaluating should fail when the containers are not passed in (i.e used outside a SubFactory). """ - def __init__(self, function, strict=True, *args, **kwargs): - super(ContainerAttribute, self).__init__(*args, **kwargs) + def __init__(self, function, strict=True): + super().__init__() self.function = function self.strict = strict - def evaluate(self, sequence, obj, create, extra=None, containers=()): + def evaluate(self, instance, step, extra): """Evaluate the current ContainerAttribute. Args: @@ -238,43 +331,25 @@ def evaluate(self, sequence, obj, create, extra=None, containers=()): being evaluated in a chain, each item being a future field of next one. """ - if self.strict and not containers: + # Strip the current instance from the chain + chain = step.chain[1:] + if self.strict and not chain: raise TypeError( "A ContainerAttribute in 'strict' mode can only be used " "within a SubFactory.") - return self.function(obj, containers) + return self.function(instance, chain) -class ParameteredAttribute(OrderedDeclaration): +class ParameteredAttribute(BaseDeclaration): """Base class for attributes expecting parameters. Attributes: - defaults (dict): Default values for the paramters. + defaults (dict): Default values for the parameters. May be overridden by call-time parameters. - - Class attributes: - CONTAINERS_FIELD (str): name of the field, if any, where container - information (e.g for SubFactory) should be stored. If empty, - containers data isn't merged into generate() parameters. """ - CONTAINERS_FIELD = '__containers' - - # Whether to add the current object to the stack of containers - EXTEND_CONTAINERS = False - - def __init__(self, **kwargs): - super(ParameteredAttribute, self).__init__() - self.defaults = kwargs - - def _prepare_containers(self, obj, containers=()): - if self.EXTEND_CONTAINERS: - return (obj,) + tuple(containers) - - return containers - - def evaluate(self, sequence, obj, create, extra=None, containers=()): + def evaluate(self, instance, step, extra): """Evaluate the current definition and fill its attributes. Uses attributes definition in the following order: @@ -282,23 +357,15 @@ def evaluate(self, sequence, obj, create, extra=None, containers=()): - additional values defined when instantiating the containing factory Args: - create (bool): whether the parent factory is being 'built' or - 'created' - extra (containers.DeclarationDict): extra values that should - override the defaults - containers (list of LazyStub): List of LazyStub for the chain of - factories being evaluated, the calling stub being first. + instance (builder.Resolver): The object holding currently computed + attributes + step: a factory.builder.BuildStep + extra (dict): additional, call-time added kwargs + for the step. """ - defaults = dict(self.defaults) - if extra: - defaults.update(extra) - if self.CONTAINERS_FIELD: - containers = self._prepare_containers(obj, containers) - defaults[self.CONTAINERS_FIELD] = containers + return self.generate(step, extra) - return self.generate(sequence, obj, create, defaults) - - def generate(self, sequence, obj, create, params): # pragma: no cover + def generate(self, step, params): """Actually generate the related attribute. Args: @@ -315,7 +382,7 @@ def generate(self, sequence, obj, create, params): # pragma: no cover raise NotImplementedError() -class _FactoryWrapper(object): +class _FactoryWrapper: """Handle a 'factory' arg. Such args can be either a Factory subclass, or a fully qualified import @@ -327,11 +394,11 @@ def __init__(self, factory_or_path): if isinstance(factory_or_path, type): self.factory = factory_or_path else: - if not (compat.is_string(factory_or_path) and '.' in factory_or_path): + if not (isinstance(factory_or_path, str) and '.' in factory_or_path): raise ValueError( - "A factory= argument must receive either a class " - "or the fully qualified path to a Factory subclass; got " - "%r instead." % factory_or_path) + "A factory= argument must receive either a class " + "or the fully qualified path to a Factory subclass; got " + "%r instead." % factory_or_path) self.module, self.name = factory_or_path.rsplit('.', 1) def get(self): @@ -344,12 +411,12 @@ def get(self): def __repr__(self): if self.factory is None: - return '<_FactoryImport: %s.%s>' % (self.module, self.name) + return f'<_FactoryImport: {self.module}.{self.name}>' else: - return '<_FactoryImport: %s>' % self.factory.__class__ + return f'<_FactoryImport: {self.factory.__class__}>' -class SubFactory(ParameteredAttribute): +class SubFactory(BaseDeclaration): """Base class for attributes based upon a sub-factory. Attributes: @@ -358,114 +425,239 @@ class SubFactory(ParameteredAttribute): factory (base.Factory): the wrapped factory """ - EXTEND_CONTAINERS = True + # Whether to align the attribute's sequence counter to the holding + # factory's sequence counter + FORCE_SEQUENCE = False + UNROLL_CONTEXT_BEFORE_EVALUATION = False def __init__(self, factory, **kwargs): - super(SubFactory, self).__init__(**kwargs) + super().__init__(**kwargs) self.factory_wrapper = _FactoryWrapper(factory) def get_factory(self): """Retrieve the wrapped factory.Factory subclass.""" return self.factory_wrapper.get() - def generate(self, sequence, obj, create, params): + def evaluate(self, instance, step, extra): """Evaluate the current definition and fill its attributes. Args: - create (bool): whether the subfactory should call 'build' or - 'create' - params (containers.DeclarationDict): extra values that should - override the wrapped factory's defaults + step: a factory.builder.BuildStep + params (dict): additional, call-time added kwargs + for the step. """ subfactory = self.get_factory() - logger.debug("SubFactory: Instantiating %s.%s(%s), create=%r", + logger.debug( + "SubFactory: Instantiating %s.%s(%s), create=%r", subfactory.__module__, subfactory.__name__, - utils.log_pprint(kwargs=params), - create, + utils.log_pprint(kwargs=extra), + step, ) - return subfactory.simple_generate(create, **params) + force_sequence = step.sequence if self.FORCE_SEQUENCE else None + return step.recurse(subfactory, extra, force_sequence=force_sequence) class Dict(SubFactory): """Fill a dict with usual declarations.""" - def __init__(self, params, dict_factory='factory.DictFactory'): - super(Dict, self).__init__(dict_factory, **dict(params)) + FORCE_SEQUENCE = True - def generate(self, sequence, obj, create, params): - dict_factory = self.get_factory() - logger.debug("Dict: Building dict(%s)", utils.log_pprint(kwargs=params)) - return dict_factory.simple_generate(create, - __sequence=sequence, - **params) + def __init__(self, params, dict_factory='factory.DictFactory'): + super().__init__(dict_factory, **dict(params)) class List(SubFactory): """Fill a list with standard declarations.""" + FORCE_SEQUENCE = True + def __init__(self, params, list_factory='factory.ListFactory'): - params = dict((str(i), v) for i, v in enumerate(params)) - super(List, self).__init__(list_factory, **params) + params = {str(i): v for i, v in enumerate(params)} + super().__init__(list_factory, **params) - def generate(self, sequence, obj, create, params): - list_factory = self.get_factory() - logger.debug('List: Building list(%s)', - utils.log_pprint(args=[v for _i, v in sorted(params.items())]), - ) - return list_factory.simple_generate(create, - __sequence=sequence, - **params) +# Parameters +# ========== -class ExtractionContext(object): - """Private class holding all required context from extraction to postgen.""" - def __init__(self, value=None, did_extract=False, extra=None, for_field=''): - self.value = value - self.did_extract = did_extract - self.extra = extra or {} - self.for_field = for_field - def __repr__(self): - return 'ExtractionContext(%r, %r, %r)' % ( - self.value, - self.did_extract, - self.extra, +class Skip: + def __bool__(self): + return False + + +SKIP = Skip() + + +class Maybe(BaseDeclaration): + def __init__(self, decider, yes_declaration=SKIP, no_declaration=SKIP): + super().__init__() + + if enums.get_builder_phase(decider) is None: + # No builder phase => flat value + decider = SelfAttribute(decider, default=None) + + self.decider = decider + self.yes = yes_declaration + self.no = no_declaration + + phases = { + 'yes_declaration': enums.get_builder_phase(yes_declaration), + 'no_declaration': enums.get_builder_phase(no_declaration), + } + used_phases = {phase for phase in phases.values() if phase is not None} + + if len(used_phases) > 1: + raise TypeError(f"Inconsistent phases for {self!r}: {phases!r}") + + self.FACTORY_BUILDER_PHASE = used_phases.pop() if used_phases else enums.BuilderPhase.ATTRIBUTE_RESOLUTION + + def evaluate_post(self, instance, step, overrides): + """Handle post-generation declarations""" + decider_phase = enums.get_builder_phase(self.decider) + if decider_phase == enums.BuilderPhase.ATTRIBUTE_RESOLUTION: + # Note: we work on the *builder stub*, not on the actual instance. + # This gives us access to all Params-level definitions. + choice = self.decider.evaluate_pre( + instance=step.stub, step=step, overrides=overrides) + else: + assert decider_phase == enums.BuilderPhase.POST_INSTANTIATION + choice = self.decider.evaluate_post( + instance=instance, step=step, overrides={}) + + target = self.yes if choice else self.no + if enums.get_builder_phase(target) == enums.BuilderPhase.POST_INSTANTIATION: + return target.evaluate_post( + instance=instance, + step=step, + overrides=overrides, + ) + else: + # Flat value (can't be ATTRIBUTE_RESOLUTION, checked in __init__) + return target + + def evaluate_pre(self, instance, step, overrides): + choice = self.decider.evaluate_pre(instance=instance, step=step, overrides={}) + target = self.yes if choice else self.no + # The value can't be POST_INSTANTIATION, checked in __init__; + # evaluate it as `evaluate_pre` + return self._unwrap_evaluate_pre( + target, + instance=instance, + step=step, + overrides=overrides, ) + def __repr__(self): + return f'Maybe({self.decider!r}, yes={self.yes!r}, no={self.no!r})' -class PostGenerationDeclaration(object): - """Declarations to be called once the target object has been generated.""" - def extract(self, name, attrs): - """Extract relevant attributes from a dict. +class Parameter(utils.OrderedBase): + """A complex parameter, to be used in a Factory.Params section. + + Must implement: + - A "compute" function, performing the actual declaration override + - Optionally, a get_revdeps() function (to compute other parameters it may alter) + """ + + def as_declarations(self, field_name, declarations): + """Compute the overrides for this parameter. Args: - name (str): the name at which this PostGenerationDeclaration was - defined in the declarations - attrs (dict): the attribute dict from which values should be - extracted + - field_name (str): the field this parameter is installed at + - declarations (dict): the global factory declarations Returns: - (object, dict): a tuple containing the attribute at 'name' (if - provided) and a dict of extracted attributes + dict: the declarations to override """ - try: - extracted = attrs.pop(name) - did_extract = True - except KeyError: - extracted = None - did_extract = False + raise NotImplementedError() + + def get_revdeps(self, parameters): + """Retrieve the list of other parameters modified by this one.""" + return [] + + +class SimpleParameter(Parameter): + def __init__(self, value): + super().__init__() + self.value = value + + def as_declarations(self, field_name, declarations): + return { + field_name: self.value, + } + + @classmethod + def wrap(cls, value): + if not isinstance(value, Parameter): + return cls(value) + value.touch_creation_counter() + return value + + +class Trait(Parameter): + """The simplest complex parameter, it enables a bunch of new declarations based on a boolean flag.""" + def __init__(self, **overrides): + super().__init__() + self.overrides = overrides + + def as_declarations(self, field_name, declarations): + overrides = {} + for maybe_field, new_value in self.overrides.items(): + overrides[maybe_field] = Maybe( + decider=SelfAttribute( + '%s.%s' % ( + '.' * maybe_field.count(enums.SPLITTER), + field_name, + ), + default=False, + ), + yes_declaration=new_value, + no_declaration=declarations.get(maybe_field, SKIP), + ) + return overrides - kwargs = utils.extract_dict(name, attrs) - return ExtractionContext(extracted, did_extract, kwargs, name) + def get_revdeps(self, parameters): + """This might alter fields it's injecting.""" + return [param for param in parameters if param in self.overrides] - def call(self, obj, create, extraction_context): # pragma: no cover + def __repr__(self): + return '%s(%s)' % ( + self.__class__.__name__, + ', '.join('%s=%r' % t for t in self.overrides.items()) + ) + + +# Post-generation +# =============== + + +class PostGenerationContext(T.NamedTuple): + value_provided: bool + value: T.Any + extra: T.Dict[str, T.Any] + + +class PostGenerationDeclaration(BaseDeclaration): + """Declarations to be called once the model object has been generated.""" + + FACTORY_BUILDER_PHASE = enums.BuilderPhase.POST_INSTANTIATION + + def evaluate_post(self, instance, step, overrides): + context = self.unroll_context(instance, step, overrides) + postgen_context = PostGenerationContext( + value_provided=bool('' in context), + value=context.get(''), + extra={k: v for k, v in context.items() if k != ''}, + ) + return self.call(instance, step, postgen_context) + + def call(self, instance, step, context): # pragma: no cover """Call this hook; no return value is expected. Args: - obj (object): the newly generated object - create (bool): whether the object was 'built' or 'created' - extraction_context: An ExtractionContext containing values + instance (object): the newly generated object + step (bool): whether the object was 'built' or 'created' + context: a declarations.PostGenerationContext containing values extracted from the containing factory's declaration """ raise NotImplementedError() @@ -474,20 +666,22 @@ def call(self, obj, create, extraction_context): # pragma: no cover class PostGeneration(PostGenerationDeclaration): """Calls a given function once the object has been generated.""" def __init__(self, function): - super(PostGeneration, self).__init__() + super().__init__() self.function = function - def call(self, obj, create, extraction_context): - logger.debug('PostGeneration: Calling %s.%s(%s)', + def call(self, instance, step, context): + logger.debug( + "PostGeneration: Calling %s.%s(%s)", self.function.__module__, self.function.__name__, utils.log_pprint( - (obj, create, extraction_context.value), - extraction_context.extra, + (instance, step), + context._asdict(), ), ) - return self.function(obj, create, - extraction_context.value, **extraction_context.extra) + create = step.builder.strategy == enums.CREATE_STRATEGY + return self.function( + instance, create, context.value, **context.extra) class RelatedFactory(PostGenerationDeclaration): @@ -500,16 +694,10 @@ class RelatedFactory(PostGenerationDeclaration): calling the related factory """ + UNROLL_CONTEXT_BEFORE_EVALUATION = False + def __init__(self, factory, factory_related_name='', **defaults): - super(RelatedFactory, self).__init__() - if factory_related_name == '' and defaults.get('name') is not None: - warnings.warn( - "Usage of RelatedFactory(SomeFactory, name='foo') is deprecated" - " and will be removed in the future. Please use the" - " RelatedFactory(SomeFactory, 'foo') or" - " RelatedFactory(SomeFactory, factory_related_name='foo')" - " syntax instead", PendingDeprecationWarning, 2) - factory_related_name = defaults.pop('name') + super().__init__() self.name = factory_related_name self.defaults = defaults @@ -519,29 +707,58 @@ def get_factory(self): """Retrieve the wrapped factory.Factory subclass.""" return self.factory_wrapper.get() - def call(self, obj, create, extraction_context): + def call(self, instance, step, context): factory = self.get_factory() - if extraction_context.did_extract: + if context.value_provided: # The user passed in a custom value - logger.debug('RelatedFactory: Using provided %r instead of ' - 'generating %s.%s.', - extraction_context.value, - factory.__module__, factory.__name__, + logger.debug( + "RelatedFactory: Using provided %r instead of generating %s.%s.", + context.value, + factory.__module__, factory.__name__, ) - return extraction_context.value + return context.value passed_kwargs = dict(self.defaults) - passed_kwargs.update(extraction_context.extra) + passed_kwargs.update(context.extra) if self.name: - passed_kwargs[self.name] = obj + passed_kwargs[self.name] = instance - logger.debug('RelatedFactory: Generating %s.%s(%s)', + logger.debug( + "RelatedFactory: Generating %s.%s(%s)", factory.__module__, factory.__name__, - utils.log_pprint((create,), passed_kwargs), + utils.log_pprint((step,), passed_kwargs), ) - return factory.simple_generate(create, **passed_kwargs) + return step.recurse(factory, passed_kwargs) + + +class RelatedFactoryList(RelatedFactory): + """Calls a factory 'size' times once the object has been generated. + + Attributes: + factory (Factory): the factory to call "size-times" + defaults (dict): extra declarations for calling the related factory + factory_related_name (str): the name to use to refer to the generated + object when calling the related factory + size (int|lambda): the number of times 'factory' is called, ultimately + returning a list of 'factory' objects w/ size 'size'. + """ + + def __init__(self, factory, factory_related_name='', size=2, **defaults): + self.size = size + super().__init__(factory, factory_related_name, **defaults) + + def call(self, instance, step, context): + parent = super() + return [ + parent.call(instance, step, context) + for i in range(self.size if isinstance(self.size, int) else self.size()) + ] + + +class NotProvided: + pass class PostGenerationMethodCall(PostGenerationDeclaration): @@ -558,27 +775,32 @@ class UserFactory(factory.Factory): password = factory.PostGenerationMethodCall('set_pass', password='') """ def __init__(self, method_name, *args, **kwargs): - super(PostGenerationMethodCall, self).__init__() + super().__init__() + if len(args) > 1: + raise errors.InvalidDeclarationError( + "A PostGenerationMethodCall can only handle 1 positional argument; " + "please provide other parameters through keyword arguments." + ) self.method_name = method_name - self.method_args = args + self.method_arg = args[0] if args else NotProvided self.method_kwargs = kwargs - def call(self, obj, create, extraction_context): - if not extraction_context.did_extract: - passed_args = self.method_args - - elif len(self.method_args) <= 1: - # Max one argument expected - passed_args = (extraction_context.value,) + def call(self, instance, step, context): + if not context.value_provided: + if self.method_arg is NotProvided: + args = () + else: + args = (self.method_arg,) else: - passed_args = tuple(extraction_context.value) - - passed_kwargs = dict(self.method_kwargs) - passed_kwargs.update(extraction_context.extra) - method = getattr(obj, self.method_name) - logger.debug('PostGenerationMethodCall: Calling %r.%s(%s)', - obj, + args = (context.value,) + + kwargs = dict(self.method_kwargs) + kwargs.update(context.extra) + method = getattr(instance, self.method_name) + logger.debug( + "PostGenerationMethodCall: Calling %r.%s(%s)", + instance, self.method_name, - utils.log_pprint(passed_args, passed_kwargs), + utils.log_pprint(args, kwargs), ) - return method(*passed_args, **passed_kwargs) + return method(*args, **kwargs) diff --git a/factory/django.py b/factory/django.py index 016586d2..b53fd5b5 100644 --- a/factory/django.py +++ b/factory/django.py @@ -1,52 +1,79 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - - -from __future__ import absolute_import -from __future__ import unicode_literals +# Copyright: See the LICENSE file. -import os """factory_boy extensions for use with the Django framework.""" -try: - from django.core import files as django_files -except ImportError as e: # pragma: no cover - django_files = None - import_failure = e +import functools +import io +import logging +import os +import warnings +from typing import Dict, TypeVar + +from django.contrib.auth.hashers import make_password +from django.core import files as django_files +from django.db import IntegrityError + +from . import base, declarations, errors + +logger = logging.getLogger('factory.generate') -from . import base -from . import declarations -from .compat import BytesIO, is_string +DEFAULT_DB_ALIAS = 'default' # Same as django.db.DEFAULT_DB_ALIAS +T = TypeVar("T") -def require_django(): - """Simple helper to ensure Django is available.""" - if django_files is None: # pragma: no cover - raise import_failure +_LAZY_LOADS: Dict[str, object] = {} -class DjangoModelFactory(base.Factory): +def get_model(app, model): + """Wrapper around django's get_model.""" + if 'get_model' not in _LAZY_LOADS: + _lazy_load_get_model() + + _get_model = _LAZY_LOADS['get_model'] + return _get_model(app, model) + + +def _lazy_load_get_model(): + """Lazy loading of get_model. + + get_model loads django.conf.settings, which may fail if + the settings haven't been configured yet. + """ + from django import apps as django_apps + _LAZY_LOADS['get_model'] = django_apps.apps.get_model + + +class DjangoOptions(base.FactoryOptions): + def _build_default_options(self): + return super()._build_default_options() + [ + base.OptionDefault('django_get_or_create', (), inherit=True), + base.OptionDefault('database', DEFAULT_DB_ALIAS, inherit=True), + base.OptionDefault('skip_postgeneration_save', False, inherit=True), + ] + + def _get_counter_reference(self): + counter_reference = super()._get_counter_reference() + if (counter_reference == self.base_factory + and self.base_factory._meta.model is not None + and self.base_factory._meta.model._meta.abstract + and self.model is not None + and not self.model._meta.abstract): + # Target factory is for an abstract model, yet we're for another, + # concrete subclass => don't reuse the counter. + return self.factory + return counter_reference + + def get_model_class(self): + if isinstance(self.model, str) and '.' in self.model: + app, model_name = self.model.split('.', 1) + self.model = get_model(app, model_name) + + return self.model + + +class DjangoModelFactory(base.Factory[T]): """Factory for Django models. This makes sure that the 'sequence' field of created objects is a new id. @@ -55,122 +82,158 @@ class DjangoModelFactory(base.Factory): handle those for non-numerical primary keys. """ - ABSTRACT_FACTORY = True # Optional, but explicit. - FACTORY_DJANGO_GET_OR_CREATE = () + _options_class = DjangoOptions + _original_params = None - _associated_model = None + class Meta: + abstract = True # Optional, but explicit. @classmethod - def _load_target_class(cls): - associated_class = super(DjangoModelFactory, cls)._load_target_class() + def _load_model_class(cls, definition): - if is_string(associated_class) and '.' in associated_class: - app, model = associated_class.split('.', 1) - if cls._associated_model is None: - from django.db.models import loading as django_loading - cls._associated_model = django_loading.get_model(app, model) - return cls._associated_model + if isinstance(definition, str) and '.' in definition: + app, model = definition.split('.', 1) + return get_model(app, model) - return associated_class + return definition @classmethod - def _get_manager(cls, target_class): + def _get_manager(cls, model_class): + if model_class is None: + raise errors.AssociatedClassError( + f"No model set on {cls.__module__}.{cls.__name__}.Meta") + try: - return target_class._default_manager # pylint: disable=W0212 + manager = model_class.objects except AttributeError: - return target_class.objects - - @classmethod - def _setup_next_sequence(cls): - """Compute the next available PK, based on the 'pk' database field.""" + # When inheriting from an abstract model with a custom + # manager, the class has no 'objects' field. + manager = model_class._default_manager - model = cls._load_target_class() # pylint: disable=E1101 - manager = cls._get_manager(model) + if cls._meta.database != DEFAULT_DB_ALIAS: + manager = manager.using(cls._meta.database) + return manager - try: - return 1 + manager.values_list('pk', flat=True - ).order_by('-pk')[0] - except (IndexError, TypeError): - # IndexError: No instance exist yet - # TypeError: pk isn't an integer type - return 1 + @classmethod + def _generate(cls, strategy, params): + # Original params are used in _get_or_create if it cannot build an + # object initially due to an IntegrityError being raised + cls._original_params = params + return super()._generate(strategy, params) @classmethod - def _get_or_create(cls, target_class, *args, **kwargs): + def _get_or_create(cls, model_class, *args, **kwargs): """Create an instance of the model through objects.get_or_create.""" - manager = cls._get_manager(target_class) + manager = cls._get_manager(model_class) - assert 'defaults' not in cls.FACTORY_DJANGO_GET_OR_CREATE, ( + assert 'defaults' not in cls._meta.django_get_or_create, ( "'defaults' is a reserved keyword for get_or_create " - "(in %s.FACTORY_DJANGO_GET_OR_CREATE=%r)" - % (cls, cls.FACTORY_DJANGO_GET_OR_CREATE)) + "(in %s._meta.django_get_or_create=%r)" + % (cls, cls._meta.django_get_or_create)) key_fields = {} - for field in cls.FACTORY_DJANGO_GET_OR_CREATE: + for field in cls._meta.django_get_or_create: + if field not in kwargs: + raise errors.FactoryError( + "django_get_or_create - " + "Unable to find initialization value for '%s' in factory %s" % + (field, cls.__name__)) key_fields[field] = kwargs.pop(field) key_fields['defaults'] = kwargs - obj, _created = manager.get_or_create(*args, **key_fields) - return obj + try: + instance, _created = manager.get_or_create(*args, **key_fields) + except IntegrityError as e: + + if cls._original_params is None: + raise e + + get_or_create_params = { + lookup: value + for lookup, value in cls._original_params.items() + if lookup in cls._meta.django_get_or_create + } + if get_or_create_params: + try: + instance = manager.get(**get_or_create_params) + except manager.model.DoesNotExist: + # Original params are not a valid lookup and triggered a create(), + # that resulted in an IntegrityError. Follow Django’s behavior. + raise e + else: + raise e + + return instance @classmethod - def _create(cls, target_class, *args, **kwargs): + def _create(cls, model_class, *args, **kwargs): """Create an instance of the model, and save it to the database.""" - manager = cls._get_manager(target_class) - - if cls.FACTORY_DJANGO_GET_OR_CREATE: - return cls._get_or_create(target_class, *args, **kwargs) + if cls._meta.django_get_or_create: + return cls._get_or_create(model_class, *args, **kwargs) + manager = cls._get_manager(model_class) return manager.create(*args, **kwargs) + # DEPRECATED. Remove this override with the next major release. @classmethod - def _after_postgeneration(cls, obj, create, results=None): + def _after_postgeneration(cls, instance, create, results=None): """Save again the instance if creating and at least one hook ran.""" - if create and results: + if create and results and not cls._meta.skip_postgeneration_save: + warnings.warn( + f"{cls.__name__}._after_postgeneration will stop saving the instance " + "after postgeneration hooks in the next major release.\n" + "If the save call is extraneous, set skip_postgeneration_save=True " + f"in the {cls.__name__}.Meta.\n" + "To keep saving the instance, move the save call to your " + "postgeneration hooks or override _after_postgeneration.", + DeprecationWarning, + ) # Some post-generation hooks ran, and may have modified us. - obj.save() + instance.save() + + +class Password(declarations.Transformer): + def __init__(self, password, transform=make_password, **kwargs): + super().__init__(password, transform=transform, **kwargs) -class FileField(declarations.PostGenerationDeclaration): +class FileField(declarations.BaseDeclaration): """Helper to fill in django.db.models.FileField from a Factory.""" DEFAULT_FILENAME = 'example.dat' - def __init__(self, **defaults): - require_django() - self.defaults = defaults - super(FileField, self).__init__() - def _make_data(self, params): """Create data for the field.""" return params.get('data', b'') - def _make_content(self, extraction_context): + def _make_content(self, params): path = '' - params = dict(self.defaults) - params.update(extraction_context.extra) - if params.get('from_path') and params.get('from_file'): + from_path = params.get('from_path') + from_file = params.get('from_file') + from_func = params.get('from_func') + + if len([p for p in (from_path, from_file, from_func) if p]) > 1: raise ValueError( - "At most one argument from 'from_file' and 'from_path' should " + "At most one argument from 'from_file', 'from_path', and 'from_func' should " "be non-empty when calling factory.django.FileField." ) - if extraction_context.did_extract: - # Should be a django.core.files.File - content = extraction_context.value - path = content.name + if from_path: + path = from_path + with open(path, 'rb') as f: + content = django_files.base.ContentFile(f.read()) - elif params.get('from_path'): - path = params['from_path'] - f = open(path, 'rb') - content = django_files.File(f, name=path) - - elif params.get('from_file'): - f = params['from_file'] + elif from_file: + f = from_file content = django_files.File(f) path = content.name + elif from_func: + func = from_func + content = django_files.File(func()) + path = content.name + else: data = self._make_data(params) content = django_files.base.ContentFile(data) @@ -183,19 +246,10 @@ def _make_content(self, extraction_context): filename = params.get('filename', default_filename) return filename, content - def call(self, obj, create, extraction_context): + def evaluate(self, instance, step, extra): """Fill in the field.""" - if extraction_context.did_extract and extraction_context.value is None: - # User passed an empty value, don't fill - return - - filename, content = self._make_content(extraction_context) - field_file = getattr(obj, extraction_context.for_field) - try: - field_file.save(filename, content, save=create) - finally: - content.file.close() - return field_file + filename, content = self._make_content(extra) + return django_files.File(content.file, filename) class ImageField(FileField): @@ -204,18 +258,94 @@ class ImageField(FileField): def _make_data(self, params): # ImageField (both django's and factory_boy's) require PIL. # Try to import it along one of its known installation paths. - try: - from PIL import Image - except ImportError: - import Image + from PIL import Image width = params.get('width', 100) height = params.get('height', width) color = params.get('color', 'blue') image_format = params.get('format', 'JPEG') + image_palette = params.get('palette', 'RGB') - thumb = Image.new('RGB', (width, height), color) - thumb_io = BytesIO() - thumb.save(thumb_io, format=image_format) + thumb_io = io.BytesIO() + with Image.new(image_palette, (width, height), color) as thumb: + thumb.save(thumb_io, format=image_format) return thumb_io.getvalue() + +class mute_signals: + """Temporarily disables and then restores any django signals. + + Args: + *signals (django.dispatch.dispatcher.Signal): any django signals + + Examples: + with mute_signals(pre_init): + user = UserFactory.build() + ... + + @mute_signals(pre_save, post_save) + class UserFactory(factory.Factory): + ... + + @mute_signals(post_save) + def generate_users(): + UserFactory.create_batch(10) + """ + + def __init__(self, *signals): + self.signals = signals + self.paused = {} + + def __enter__(self): + for signal in self.signals: + logger.debug('mute_signals: Disabling signal handlers %r', + signal.receivers) + + # Note that we're using implementation details of + # django.signals, since arguments to signal.connect() + # are lost in signal.receivers + self.paused[signal] = signal.receivers + signal.receivers = [] + + def __exit__(self, exc_type, exc_value, traceback): + for signal, receivers in self.paused.items(): + logger.debug('mute_signals: Restoring signal handlers %r', + receivers) + + signal.receivers = receivers + signal.receivers + with signal.lock: + # Django uses some caching for its signals. + # Since we're bypassing signal.connect and signal.disconnect, + # we have to keep messing with django's internals. + signal.sender_receivers_cache.clear() + self.paused = {} + + def copy(self): + return mute_signals(*self.signals) + + def __call__(self, callable_obj): + if isinstance(callable_obj, base.FactoryMetaClass): + # Retrieve __func__, the *actual* callable object. + callable_obj._create = self.wrap_method(callable_obj._create.__func__) + callable_obj._generate = self.wrap_method(callable_obj._generate.__func__) + callable_obj._after_postgeneration = self.wrap_method( + callable_obj._after_postgeneration.__func__ + ) + return callable_obj + + else: + @functools.wraps(callable_obj) + def wrapper(*args, **kwargs): + # A mute_signals() object is not reentrant; use a copy every time. + with self.copy(): + return callable_obj(*args, **kwargs) + return wrapper + + def wrap_method(self, method): + @classmethod + @functools.wraps(method) + def wrapped_method(*args, **kwargs): + # A mute_signals() object is not reentrant; use a copy every time. + with self.copy(): + return method(*args, **kwargs) + return wrapped_method diff --git a/factory/enums.py b/factory/enums.py new file mode 100644 index 00000000..02f686e3 --- /dev/null +++ b/factory/enums.py @@ -0,0 +1,24 @@ +# Copyright: See the LICENSE file. + +# Strategies +BUILD_STRATEGY = 'build' +CREATE_STRATEGY = 'create' +STUB_STRATEGY = 'stub' + + +#: String for splitting an attribute name into a +#: (subfactory_name, subfactory_field) tuple. +SPLITTER = '__' + + +# Target build phase, for declarations +class BuilderPhase: + #: During attribute resolution/computation + ATTRIBUTE_RESOLUTION = 'attributes' + + #: Once the target object has been built + POST_INSTANTIATION = 'post_instance' + + +def get_builder_phase(obj): + return getattr(obj, 'FACTORY_BUILDER_PHASE', None) diff --git a/factory/errors.py b/factory/errors.py new file mode 100644 index 00000000..06e8aab2 --- /dev/null +++ b/factory/errors.py @@ -0,0 +1,29 @@ +# Copyright: See the LICENSE file. + + +class FactoryError(Exception): + """Any exception raised by factory_boy.""" + + +class AssociatedClassError(FactoryError): + """Exception for Factory subclasses lacking Meta.model.""" + + +class UnknownStrategy(FactoryError): + """Raised when a factory uses an unknown strategy.""" + + +class UnsupportedStrategy(FactoryError): + """Raised when trying to use a strategy on an incompatible Factory.""" + + +class CyclicDefinitionError(FactoryError): + """Raised when a cyclical declaration occurs.""" + + +class InvalidDeclarationError(FactoryError): + """Raised when a sub-declaration has no related declaration. + + This means that the user declared 'foo__bar' without adding a declaration + at 'foo'. + """ diff --git a/factory/faker.py b/factory/faker.py new file mode 100644 index 00000000..88ae644c --- /dev/null +++ b/factory/faker.py @@ -0,0 +1,78 @@ +# Copyright: See the LICENSE file. + + +"""Additional declarations for "faker" attributes. + +Usage: + + class MyFactory(factory.Factory): + class Meta: + model = MyProfile + + first_name = factory.Faker('name') +""" + + +import contextlib +from typing import Dict + +import faker +import faker.config + +from . import declarations + + +class Faker(declarations.BaseDeclaration): + """Wrapper for 'faker' values. + + Args: + provider (str): the name of the Faker field + locale (str): the locale to use for the faker + + All other kwargs will be passed to the underlying provider + (e.g ``factory.Faker('ean', length=10)`` + calls ``faker.Faker.ean(length=10)``) + + Usage: + >>> foo = factory.Faker('name') + """ + def __init__(self, provider, **kwargs): + locale = kwargs.pop('locale', None) + self.provider = provider + super().__init__( + locale=locale, + **kwargs) + + def evaluate(self, instance, step, extra): + locale = extra.pop('locale') + subfaker = self._get_faker(locale) + return subfaker.format(self.provider, **extra) + + _FAKER_REGISTRY: Dict[str, faker.Faker] = {} + _DEFAULT_LOCALE = faker.config.DEFAULT_LOCALE + + @classmethod + @contextlib.contextmanager + def override_default_locale(cls, locale): + old_locale = cls._DEFAULT_LOCALE + cls._DEFAULT_LOCALE = locale + try: + yield + finally: + cls._DEFAULT_LOCALE = old_locale + + @classmethod + def _get_faker(cls, locale=None): + if locale is None: + locale = cls._DEFAULT_LOCALE + + if locale not in cls._FAKER_REGISTRY: + subfaker = faker.Faker(locale=locale) + cls._FAKER_REGISTRY[locale] = subfaker + + return cls._FAKER_REGISTRY[locale] + + @classmethod + def add_provider(cls, provider, locale=None): + """Add a new Faker provider for the specified locale""" + cls._get_faker(locale).add_provider(provider) diff --git a/factory/fuzzy.py b/factory/fuzzy.py index 34949c5c..ce89d7f7 100644 --- a/factory/fuzzy.py +++ b/factory/fuzzy.py @@ -1,40 +1,24 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. """Additional declarations for "fuzzy" attribute definitions.""" -from __future__ import unicode_literals +import datetime import decimal -import random import string -import datetime +import warnings + +from . import declarations, random -from . import compat -from . import declarations +random_seed_warning = ( + "Setting a specific random seed for {} can still have varying results " + "unless you also set a specific end date. For details and potential solutions " + "see https://github.com/FactoryBoy/factory_boy/issues/331" +) -class BaseFuzzyAttribute(declarations.OrderedDeclaration): +class BaseFuzzyAttribute(declarations.BaseDeclaration): """Base class for fuzzy attributes. Custom fuzzers should override the `fuzz()` method. @@ -43,7 +27,7 @@ class BaseFuzzyAttribute(declarations.OrderedDeclaration): def fuzz(self): # pragma: no cover raise NotImplementedError() - def evaluate(self, sequence, obj, create, extra=None, containers=()): + def evaluate(self, instance, step, extra): return self.fuzz() @@ -55,8 +39,8 @@ class FuzzyAttribute(BaseFuzzyAttribute): random value. """ - def __init__(self, fuzzer, **kwargs): - super(FuzzyAttribute, self).__init__(**kwargs) + def __init__(self, fuzzer): + super().__init__() self.fuzzer = fuzzer def fuzz(self): @@ -80,51 +64,64 @@ class FuzzyText(BaseFuzzyAttribute): not important. """ - def __init__(self, prefix='', length=12, suffix='', - chars=string.ascii_letters, **kwargs): - super(FuzzyText, self).__init__(**kwargs) + def __init__(self, prefix='', length=12, suffix='', chars=string.ascii_letters): + super().__init__() self.prefix = prefix self.suffix = suffix self.length = length self.chars = tuple(chars) # Unroll iterators def fuzz(self): - chars = [random.choice(self.chars) for _i in range(self.length)] + chars = [random.randgen.choice(self.chars) for _i in range(self.length)] return self.prefix + ''.join(chars) + self.suffix class FuzzyChoice(BaseFuzzyAttribute): - """Handles fuzzy choice of an attribute.""" + """Handles fuzzy choice of an attribute. + + Args: + choices (iterable): An iterable yielding options; will only be unrolled + on the first call. + getter (callable or None): a function to parse returned values + """ - def __init__(self, choices, **kwargs): - self.choices = list(choices) - super(FuzzyChoice, self).__init__(**kwargs) + def __init__(self, choices, getter=None): + self.choices = None + self.choices_generator = choices + self.getter = getter + super().__init__() def fuzz(self): - return random.choice(self.choices) + if self.choices is None: + self.choices = list(self.choices_generator) + value = random.randgen.choice(self.choices) + if self.getter is None: + return value + return self.getter(value) class FuzzyInteger(BaseFuzzyAttribute): """Random integer within a given range.""" - def __init__(self, low, high=None, **kwargs): + def __init__(self, low, high=None, step=1): if high is None: high = low low = 0 self.low = low self.high = high + self.step = step - super(FuzzyInteger, self).__init__(**kwargs) + super().__init__() def fuzz(self): - return random.randint(self.low, self.high) + return random.randgen.randrange(self.low, self.high + 1, self.step) class FuzzyDecimal(BaseFuzzyAttribute): """Random decimal within a given range.""" - def __init__(self, low, high=None, precision=2, **kwargs): + def __init__(self, low, high=None, precision=2): if high is None: high = low low = 0.0 @@ -133,19 +130,41 @@ def __init__(self, low, high=None, precision=2, **kwargs): self.high = high self.precision = precision - super(FuzzyDecimal, self).__init__(**kwargs) + super().__init__() def fuzz(self): - base = compat.float_to_decimal(random.uniform(self.low, self.high)) + base = decimal.Decimal(str(random.randgen.uniform(self.low, self.high))) return base.quantize(decimal.Decimal(10) ** -self.precision) +class FuzzyFloat(BaseFuzzyAttribute): + """Random float within a given range.""" + + def __init__(self, low, high=None, precision=15): + if high is None: + high = low + low = 0 + + self.low = low + self.high = high + self.precision = precision + + super().__init__() + + def fuzz(self): + base = random.randgen.uniform(self.low, self.high) + return float(format(base, '.%dg' % self.precision)) + + class FuzzyDate(BaseFuzzyAttribute): """Random date within a given date range.""" - def __init__(self, start_date, end_date=None, **kwargs): - super(FuzzyDate, self).__init__(**kwargs) + def __init__(self, start_date, end_date=None): + super().__init__() if end_date is None: + if random.randgen.state_set: + cls_name = self.__class__.__name__ + warnings.warn(random_seed_warning.format(cls_name), stacklevel=2) end_date = datetime.date.today() if start_date > end_date: @@ -157,7 +176,7 @@ def __init__(self, start_date, end_date=None, **kwargs): self.end_date = end_date.toordinal() def fuzz(self): - return datetime.date.fromordinal(random.randint(self.start_date, self.end_date)) + return datetime.date.fromordinal(random.randgen.randint(self.start_date, self.end_date)) class BaseFuzzyDateTime(BaseFuzzyAttribute): @@ -172,13 +191,19 @@ def _check_bounds(self, start_dt, end_dt): """%s boundaries should have start <= end, got %r > %r""" % ( self.__class__.__name__, start_dt, end_dt)) + def _now(self): + raise NotImplementedError() + def __init__(self, start_dt, end_dt=None, force_year=None, force_month=None, force_day=None, force_hour=None, force_minute=None, force_second=None, - force_microsecond=None, **kwargs): - super(BaseFuzzyDateTime, self).__init__(**kwargs) + force_microsecond=None): + super().__init__() if end_dt is None: + if random.randgen.state_set: + cls_name = self.__class__.__name__ + warnings.warn(random_seed_warning.format(cls_name), stacklevel=2) end_dt = self._now() self._check_bounds(start_dt, end_dt) @@ -197,7 +222,7 @@ def fuzz(self): delta = self.end_dt - self.start_dt microseconds = delta.microseconds + 1000000 * (delta.seconds + (delta.days * 86400)) - offset = random.randint(0, microseconds) + offset = random.randgen.randint(0, microseconds) result = self.start_dt + datetime.timedelta(microseconds=offset) if self.force_year is not None: @@ -221,7 +246,7 @@ def fuzz(self): class FuzzyNaiveDateTime(BaseFuzzyDateTime): """Random naive datetime within a given range. - If no upper bound is given, will default to datetime.datetime.utcnow(). + If no upper bound is given, will default to datetime.datetime.now(). """ def _now(self): @@ -236,7 +261,7 @@ def _check_bounds(self, start_dt, end_dt): raise ValueError( "FuzzyNaiveDateTime only handles naive datetimes, got end=%r" % end_dt) - super(FuzzyNaiveDateTime, self)._check_bounds(start_dt, end_dt) + super()._check_bounds(start_dt, end_dt) class FuzzyDateTime(BaseFuzzyDateTime): @@ -247,15 +272,15 @@ class FuzzyDateTime(BaseFuzzyDateTime): """ def _now(self): - return datetime.datetime.now(tz=compat.UTC) + return datetime.datetime.now(tz=datetime.timezone.utc) def _check_bounds(self, start_dt, end_dt): if start_dt.tzinfo is None: raise ValueError( - "FuzzyDateTime only handles aware datetimes, got start=%r" + "FuzzyDateTime requires timezone-aware datetimes, got start=%r" % start_dt) if end_dt.tzinfo is None: raise ValueError( - "FuzzyDateTime only handles aware datetimes, got end=%r" + "FuzzyDateTime requires timezone-aware datetimes, got end=%r" % end_dt) - super(FuzzyDateTime, self)._check_bounds(start_dt, end_dt) + super()._check_bounds(start_dt, end_dt) diff --git a/factory/helpers.py b/factory/helpers.py index 37b41bfb..496de6e3 100644 --- a/factory/helpers.py +++ b/factory/helpers.py @@ -1,24 +1,4 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. """Simple wrappers around Factory class definition.""" @@ -26,8 +6,7 @@ import contextlib import logging -from . import base -from . import declarations +from . import base, declarations @contextlib.contextmanager @@ -40,20 +19,24 @@ def debug(logger='factory', stream=None): logger_obj.addHandler(handler) logger_obj.setLevel(logging.DEBUG) - yield - - logger_obj.setLevel(old_level) - logger_obj.removeHandler(handler) + try: + yield + finally: + logger_obj.setLevel(old_level) + logger_obj.removeHandler(handler) def make_factory(klass, **kwargs): """Create a new, simple factory for the given class.""" factory_name = '%sFactory' % klass.__name__ - kwargs[base.FACTORY_CLASS_DECLARATION] = klass + + class Meta: + model = klass + + kwargs['Meta'] = Meta base_class = kwargs.pop('FACTORY_CLASS', base.Factory) - factory_class = type(base.Factory).__new__( - type(base.Factory), factory_name, (base_class,), kwargs) + factory_class = type(base.Factory).__new__(type(base.Factory), factory_name, (base_class,), kwargs) factory_class.__name__ = '%sFactory' % klass.__name__ factory_class.__doc__ = 'Auto-generated factory for class %s' % klass return factory_class @@ -99,10 +82,6 @@ def generate_batch(klass, strategy, size, **kwargs): return make_factory(klass, **kwargs).generate_batch(strategy, size) -# We're reusing 'create' as a keyword. -# pylint: disable=W0621 - - def simple_generate(klass, create, **kwargs): """Create a factory for the given class, and simple_generate an instance.""" return make_factory(klass, **kwargs).simple_generate(create) @@ -113,9 +92,6 @@ def simple_generate_batch(klass, create, size, **kwargs): return make_factory(klass, **kwargs).simple_generate_batch(create, size) -# pylint: enable=W0621 - - def lazy_attribute(func): return declarations.LazyAttribute(func) diff --git a/factory/mogo.py b/factory/mogo.py index 48d9677d..f886ae14 100644 --- a/factory/mogo.py +++ b/factory/mogo.py @@ -1,27 +1,4 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - - -from __future__ import unicode_literals +# Copyright: See the LICENSE file. """factory_boy extensions for use with the mogo library (pymongo wrapper).""" @@ -32,14 +9,15 @@ class MogoFactory(base.Factory): """Factory for mogo objects.""" - ABSTRACT_FACTORY = True + class Meta: + abstract = True @classmethod - def _build(cls, target_class, *args, **kwargs): - return target_class.new(*args, **kwargs) + def _build(cls, model_class, *args, **kwargs): + return model_class(*args, **kwargs) @classmethod - def _create(cls, target_class, *args, **kwargs): - instance = target_class.new(*args, **kwargs) + def _create(cls, model_class, *args, **kwargs): + instance = model_class(*args, **kwargs) instance.save() return instance diff --git a/factory/mongoengine.py b/factory/mongoengine.py index 8cd3a672..eb4a8dc5 100644 --- a/factory/mongoengine.py +++ b/factory/mongoengine.py @@ -1,27 +1,4 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - - -from __future__ import unicode_literals +# Copyright: See the LICENSE file. """factory_boy extensions for use with the mongoengine library (pymongo wrapper).""" @@ -32,14 +9,17 @@ class MongoEngineFactory(base.Factory): """Factory for mongoengine objects.""" - ABSTRACT_FACTORY = True + + class Meta: + abstract = True @classmethod - def _build(cls, target_class, *args, **kwargs): - return target_class(*args, **kwargs) + def _build(cls, model_class, *args, **kwargs): + return model_class(*args, **kwargs) @classmethod - def _create(cls, target_class, *args, **kwargs): - instance = target_class(*args, **kwargs) - instance.save() + def _create(cls, model_class, *args, **kwargs): + instance = model_class(*args, **kwargs) + if instance._is_document: + instance.save() return instance diff --git a/factory/py.typed b/factory/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/factory/random.py b/factory/random.py new file mode 100644 index 00000000..73b34f09 --- /dev/null +++ b/factory/random.py @@ -0,0 +1,30 @@ +import random + +import faker.generator + +randgen = random.Random() + +randgen.state_set = False + + +def get_random_state(): + """Retrieve the state of factory.fuzzy's random generator.""" + state = randgen.getstate() + # Returned state must represent both Faker and factory_boy. + faker.generator.random.setstate(state) + return state + + +def set_random_state(state): + """Force-set the state of factory.fuzzy's random generator.""" + randgen.state_set = True + randgen.setstate(state) + + faker.generator.random.setstate(state) + + +def reseed_random(seed): + """Reseed factory.fuzzy's random generator.""" + r = random.Random(seed) + random_internal_state = r.getstate() + set_random_state(random_internal_state) diff --git a/factory/utils.py b/factory/utils.py index 48c6eede..a74e0b35 100644 --- a/factory/utils.py +++ b/factory/utils.py @@ -1,88 +1,8 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. -import collections - -#: String for splitting an attribute name into a -#: (subfactory_name, subfactory_field) tuple. -ATTR_SPLITTER = '__' - -def extract_dict(prefix, kwargs, pop=True, exclude=()): - """Extracts all values beginning with a given prefix from a dict. - - Can either 'pop' or 'get' them; - - Args: - prefix (str): the prefix to use for lookups - kwargs (dict): the dict from which values should be extracted - pop (bool): whether to use pop (True) or get (False) - exclude (iterable): list of prefixed keys that shouldn't be extracted - - Returns: - A new dict, containing values from kwargs and beginning with - prefix + ATTR_SPLITTER. That full prefix is removed from the keys - of the returned dict. - """ - prefix = prefix + ATTR_SPLITTER - extracted = {} - - for key in list(kwargs): - if key in exclude: - continue - - if key.startswith(prefix): - new_key = key[len(prefix):] - if pop: - value = kwargs.pop(key) - else: - value = kwargs[key] - extracted[new_key] = value - return extracted - - -def multi_extract_dict(prefixes, kwargs, pop=True, exclude=()): - """Extracts all values from a given list of prefixes. - Extraction will start with longer prefixes. - - Args: - prefixes (str list): the prefixes to use for lookups - kwargs (dict): the dict from which values should be extracted - pop (bool): whether to use pop (True) or get (False) - exclude (iterable): list of prefixed keys that shouldn't be extracted - - Returns: - dict(str => dict): a dict mapping each prefix to the dict of extracted - key/value. - """ - results = {} - exclude = list(exclude) - for prefix in sorted(prefixes, key=lambda x: -len(x)): - extracted = extract_dict(prefix, kwargs, pop=pop, exclude=exclude) - results[prefix] = extracted - exclude.extend( - ['%s%s%s' % (prefix, ATTR_SPLITTER, key) for key in extracted]) - - return results +import collections +import importlib def import_object(module_name, attribute_name): @@ -92,22 +12,40 @@ def import_object(module_name, attribute_name): >>> import_object('datetime', 'datetime') """ - module = __import__(module_name, {}, {}, [attribute_name], 0) + module = importlib.import_module(module_name) return getattr(module, attribute_name) -def log_pprint(args=(), kwargs=None): - kwargs = kwargs or {} - return ', '.join( - [str(arg) for arg in args] + - ['%s=%r' % item for item in kwargs.items()] - ) +class log_pprint: + """Helper for properly printing args / kwargs passed to an object. + Since it is only used with factory.debug(), the computation is + performed lazily. + """ + __slots__ = ['args', 'kwargs'] -class ResetableIterator(object): + def __init__(self, args=(), kwargs=None): + self.args = args + self.kwargs = kwargs or {} + + def __repr__(self): + return repr(str(self)) + + def __str__(self): + return ', '.join( + [ + repr(arg) for arg in self.args + ] + [ + '%s=%s' % (key, repr(value)) + for key, value in self.kwargs.items() + ] + ) + + +class ResetableIterator: """An iterator wrapper that can be 'reset()' to its start.""" def __init__(self, iterator, **kwargs): - super(ResetableIterator, self).__init__(**kwargs) + super().__init__(**kwargs) self.iterator = iter(iterator) self.past_elements = collections.deque() self.next_elements = collections.deque() @@ -117,10 +55,51 @@ def __iter__(self): if self.next_elements: yield self.next_elements.popleft() else: - value = next(self.iterator) - self.past_elements.append(value) - yield value + try: + value = next(self.iterator) + except StopIteration: + break + else: + self.past_elements.append(value) + yield value def reset(self): self.next_elements.clear() self.next_elements.extend(self.past_elements) + + +class OrderedBase: + """Marks a class as being ordered. + + Each instance (even from subclasses) will share a global creation counter. + """ + + CREATION_COUNTER_FIELD = '_creation_counter' + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if type(self) is not OrderedBase: + self.touch_creation_counter() + + def touch_creation_counter(self): + bases = type(self).__mro__ + root = bases[bases.index(OrderedBase) - 1] + if not hasattr(root, self.CREATION_COUNTER_FIELD): + setattr(root, self.CREATION_COUNTER_FIELD, 0) + next_counter = getattr(root, self.CREATION_COUNTER_FIELD) + setattr(self, self.CREATION_COUNTER_FIELD, next_counter) + setattr(root, self.CREATION_COUNTER_FIELD, next_counter + 1) + + +def sort_ordered_objects(items, getter=lambda x: x): + """Sort an iterable of OrderedBase instances. + + Args: + items (iterable): the objects to sort + getter (callable or None): a function to extract the OrderedBase instance from an object. + + Examples: + >>> sort_ordered_objects([x, y, z]) + >>> sort_ordered_objects(v.items(), getter=lambda e: e[1]) + """ + return sorted(items, key=lambda x: getattr(getter(x), OrderedBase.CREATION_COUNTER_FIELD, -1)) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..167a69a8 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,101 @@ +[metadata] +name = factory_boy +version = 3.3.4.dev0 +description = A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby. +long_description = file: README.rst +# https://docutils.sourceforge.io/FAQ.html#what-s-the-official-mime-type-for-restructuredtext-data +long_description_content_type = text/x-rst +author = Mark Sandstrom +author_email = mark@deliciouslynerdy.com +maintainer = Raphaël Barrois +maintainer_email = raphael.barrois+fboy@polytechnique.org +url = https://github.com/FactoryBoy/factory_boy +keywords = factory_boy, factory, fixtures +license = MIT +classifiers = + Development Status :: 5 - Production/Stable + Framework :: Django + Framework :: Django :: 4.2 + Framework :: Django :: 5.0 + Framework :: Django :: 5.1 + Framework :: Django :: 5.2 + Intended Audience :: Developers + License :: OSI Approved :: MIT License + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 + Programming Language :: Python :: Implementation :: CPython + Programming Language :: Python :: Implementation :: PyPy + Topic :: Software Development :: Testing + Topic :: Software Development :: Libraries :: Python Modules + +[options] +packages = factory +python_requires = >=3.8 +install_requires = + Faker>=0.7.0 + +[options.extras_require] +dev = + coverage + Django + flake8 + isort + mypy + Pillow + SQLAlchemy + mongoengine + mongomock + wheel>=0.32.0 + tox + zest.releaser[recommended] +doc = + Sphinx + sphinx_rtd_theme + sphinxcontrib-spelling + +[options.package_data] +factory = + py.typed + +[bdist_wheel] +universal = 1 + +[zest.releaser] +; semver-style versions +version-levels = 3 + +[distutils] +index-servers = pypi + +[flake8] +ignore = + # Ignore "and" at start of line. + W503 + # Ignore "do not assign a lambda expression, use a def". + E731 +max-line-length = 120 + +[isort] +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +line_length = 88 + +[coverage:run] +dynamic_context = test_function + +[coverage:report] +include= + factory/*.py + tests/*.py + +[coverage:html] +show_contexts = True diff --git a/setup.py b/setup.py index 11540618..beda28e8 100755 --- a/setup.py +++ b/setup.py @@ -1,71 +1,5 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- - -import os -import re -import sys from setuptools import setup -root_dir = os.path.abspath(os.path.dirname(__file__)) - - -def get_version(package_name): - version_re = re.compile(r"^__version__ = [\"']([\w_.-]+)[\"']$") - package_components = package_name.split('.') - path_components = package_components + ['__init__.py'] - with open(os.path.join(root_dir, *path_components)) as f: - for line in f: - match = version_re.match(line[:-1]) - if match: - return match.groups()[0] - return '0.1.0' - - -if sys.version_info[0:2] < (2, 7): # pragma: no cover - test_loader = 'unittest2:TestLoader' -else: - test_loader = 'unittest:TestLoader' - - -PACKAGE = 'factory' - - -setup( - name='factory_boy', - version=get_version(PACKAGE), - description="A verstile test fixtures replacement based on thoughtbot's factory_girl for Ruby.", - author='Mark Sandstrom', - author_email='mark@deliciouslynerdy.com', - maintainer='Raphaël Barrois', - maintainer_email='raphael.barrois+fboy@polytechnique.org', - url='https://github.com/rbarrois/factory_boy', - keywords=['factory_boy', 'factory', 'fixtures'], - packages=['factory'], - license='MIT', - setup_requires=[ - 'setuptools>=0.8', - ], - tests_require=[ - 'mock', - ], - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Framework :: Django", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.6", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.2", - "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: Implementation :: PyPy", - "Topic :: Software Development :: Testing", - "Topic :: Software Development :: Libraries :: Python Modules" - ], - test_suite='tests', - test_loader=test_loader, -) +setup() diff --git a/tests/__init__.py b/tests/__init__.py index 5b6fc55b..e69de29b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois - -from .test_base import * -from .test_containers import * -from .test_declarations import * -from .test_django import * -from .test_fuzzy import * -from .test_helpers import * -from .test_using import * -from .test_utils import * -from .test_alchemy import * -from .test_mongoengine import * diff --git a/tests/alchemyapp/models.py b/tests/alchemyapp/models.py index e0193d46..42e05176 100644 --- a/tests/alchemyapp/models.py +++ b/tests/alchemyapp/models.py @@ -1,33 +1,15 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2013 Romain Commandé -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. """Helpers for testing SQLAlchemy apps.""" from sqlalchemy import Column, Integer, Unicode, create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker + +engine_name = 'sqlite://' session = scoped_session(sessionmaker()) -engine = create_engine('sqlite://') +engine = create_engine(engine_name) session.configure(bind=engine) Base = declarative_base() @@ -39,9 +21,31 @@ class StandardModel(Base): foo = Column(Unicode(20)) +class MultiFieldModel(Base): + __tablename__ = 'MultiFieldModelTable' + + id = Column(Integer(), primary_key=True) + foo = Column(Unicode(20)) + slug = Column(Unicode(20), unique=True) + + +class MultifieldUniqueModel(Base): + __tablename__ = 'MultiFieldUniqueModelTable' + + id = Column(Integer(), primary_key=True) + slug = Column(Unicode(20), unique=True) + text = Column(Unicode(20), unique=True) + title = Column(Unicode(20), unique=True) + + class NonIntegerPk(Base): __tablename__ = 'NonIntegerPk' id = Column(Unicode(20), primary_key=True) -Base.metadata.create_all(engine) + +class SpecialFieldModel(Base): + __tablename__ = 'SpecialFieldModelTable' + + id = Column(Integer(), primary_key=True) + session = Column(Unicode(20)) diff --git a/tests/alter_time.py b/tests/alter_time.py index db0a6111..3da968d5 100644 --- a/tests/alter_time.py +++ b/tests/alter_time.py @@ -1,17 +1,14 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- # This code is in the public domain # Author: Raphaël Barrois -from __future__ import print_function - import datetime -import mock - +from unittest import mock real_datetime_class = datetime.datetime + def mock_datetime_now(target, datetime_module): """Override ``datetime.datetime.now()`` with a custom target value. @@ -22,8 +19,8 @@ def mock_datetime_now(target, datetime_module): A mock.patch context, can be used as a decorator or in a with. """ - # See http://bugs.python.org/msg68532 - # And http://docs.python.org/reference/datamodel.html#customizing-instance-and-subclass-checks + # See https://bugs.python.org/msg68532 + # And https://docs.python.org/3/reference/datamodel.html#customizing-instance-and-subclass-checks class DatetimeSubclassMeta(type): """We need to customize the __instancecheck__ method for isinstance(). @@ -34,7 +31,7 @@ class DatetimeSubclassMeta(type): def __instancecheck__(mcs, obj): return isinstance(obj, real_datetime_class) - class BaseMockedDatetime(real_datetime_class): + class MockedDatetime(real_datetime_class, metaclass=DatetimeSubclassMeta): @classmethod def now(cls, tz=None): return target.replace(tzinfo=tz) @@ -43,13 +40,12 @@ def now(cls, tz=None): def utcnow(cls): return target - # Python2 & Python3-compatible metaclass - MockedDatetime = DatetimeSubclassMeta('datetime', (BaseMockedDatetime,), {}) - return mock.patch.object(datetime_module, 'datetime', MockedDatetime) + real_date_class = datetime.date + def mock_date_today(target, datetime_module): """Override ``datetime.date.today()`` with a custom target value. @@ -59,8 +55,8 @@ def mock_date_today(target, datetime_module): A mock.patch context, can be used as a decorator or in a with. """ - # See http://bugs.python.org/msg68532 - # And http://docs.python.org/reference/datamodel.html#customizing-instance-and-subclass-checks + # See https://bugs.python.org/msg68532 + # And https://docs.python.org/3/reference/datamodel.html#customizing-instance-and-subclass-checks class DateSubclassMeta(type): """We need to customize the __instancecheck__ method for isinstance(). @@ -71,14 +67,11 @@ class DateSubclassMeta(type): def __instancecheck__(mcs, obj): return isinstance(obj, real_date_class) - class BaseMockedDate(real_date_class): + class MockedDate(real_date_class, metaclass=DateSubclassMeta): @classmethod def today(cls): return target - # Python2 & Python3-compatible metaclass - MockedDate = DateSubclassMeta('date', (BaseMockedDate,), {}) - return mock.patch.object(datetime_module, 'date', MockedDate) @@ -98,7 +91,6 @@ def main(): # pragma: no cover print("- isinstance(now, date) ->", isinstance(datetime.date.today(), datetime.date)) print("- isinstance(target, date) ->", isinstance(target_date, datetime.date)) - print("Outside mock") print("- now ->", datetime.datetime.now()) print("- isinstance(now, dt) ->", isinstance(datetime.datetime.now(), datetime.datetime)) @@ -107,7 +99,3 @@ def main(): # pragma: no cover print("- today ->", datetime.date.today()) print("- isinstance(now, date) ->", isinstance(datetime.date.today(), datetime.date)) print("- isinstance(target, date) ->", isinstance(target_date, datetime.date)) - - -if __name__ == '__main__': # pragma: no cover - main() diff --git a/tests/compat.py b/tests/compat.py deleted file mode 100644 index ff96f13e..00000000 --- a/tests/compat.py +++ /dev/null @@ -1,42 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -"""Compatibility tools for tests""" - -import sys - -is_python2 = (sys.version_info[0] == 2) - -if sys.version_info[0:2] < (2, 7): # pragma: no cover - import unittest2 as unittest -else: # pragma: no cover - import unittest - -if sys.version_info[0] == 2: # pragma: no cover - import StringIO as io -else: # pragma: no cover - import io - -if sys.version_info[0:2] < (3, 3): # pragma: no cover - import mock -else: # pragma: no cover - from unittest import mock - diff --git a/tests/cyclic/bar.py b/tests/cyclic/bar.py index fed06029..6985a11f 100644 --- a/tests/cyclic/bar.py +++ b/tests/cyclic/bar.py @@ -1,37 +1,19 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. """Helper to test circular factory dependencies.""" import factory -class Bar(object): + +class Bar: def __init__(self, foo, y): self.foo = foo self.y = y class BarFactory(factory.Factory): - FACTORY_FOR = Bar + class Meta: + model = Bar y = 13 foo = factory.SubFactory('cyclic.foo.FooFactory') - diff --git a/tests/cyclic/foo.py b/tests/cyclic/foo.py index e584ed19..31ec02cc 100644 --- a/tests/cyclic/foo.py +++ b/tests/cyclic/foo.py @@ -1,23 +1,4 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. """Helper to test circular factory dependencies.""" @@ -25,14 +6,16 @@ from . import bar as bar_mod -class Foo(object): + +class Foo: def __init__(self, bar, x): self.bar = bar self.x = x class FooFactory(factory.Factory): - FACTORY_FOR = Foo + class Meta: + model = Foo x = 42 bar = factory.SubFactory(bar_mod.BarFactory) diff --git a/tests/cyclic/self_ref.py b/tests/cyclic/self_ref.py new file mode 100644 index 00000000..b0111b07 --- /dev/null +++ b/tests/cyclic/self_ref.py @@ -0,0 +1,19 @@ +# Copyright: See the LICENSE file. + +"""Helper to test circular factory dependencies.""" + +import factory + + +class TreeElement: + def __init__(self, name, parent): + self.parent = parent + self.name = name + + +class TreeElementFactory(factory.Factory): + class Meta: + model = TreeElement + + name = factory.Sequence(lambda n: "tree%s" % n) + parent = factory.SubFactory('tests.cyclic.self_ref.TreeElementFactory') diff --git a/tests/djapp/models.py b/tests/djapp/models.py index 3f25fbbe..b7aa8794 100644 --- a/tests/djapp/models.py +++ b/tests/djapp/models.py @@ -1,39 +1,18 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - +# Copyright: See the LICENSE file. """Helpers for testing django apps.""" import os.path +from django.conf import settings +from django.db import models +from django.db.models import signals + try: from PIL import Image except ImportError: - try: - import Image - except ImportError: - Image = None + Image = None -from django.conf import settings -from django.db import models class StandardModel(models.Model): foo = models.CharField(max_length=20) @@ -44,6 +23,17 @@ class NonIntegerPk(models.Model): bar = models.CharField(max_length=20, blank=True) +class MultifieldModel(models.Model): + slug = models.SlugField(max_length=20, unique=True) + text = models.TextField() + + +class MultifieldUniqueModel(models.Model): + slug = models.SlugField(max_length=20, unique=True) + text = models.CharField(max_length=20, unique=True) + title = models.CharField(max_length=20, unique=True) + + class AbstractBase(models.Model): foo = models.CharField(max_length=20) @@ -55,9 +45,45 @@ class ConcreteSon(AbstractBase): pass +class AbstractSon(AbstractBase): + class Meta: + abstract = True + + +class ConcreteGrandSon(AbstractSon): + pass + + +class StandardSon(StandardModel): + pass + + +class PointedModel(models.Model): + foo = models.CharField(max_length=20) + + +class PointerModel(models.Model): + bar = models.CharField(max_length=20) + pointed = models.OneToOneField( + PointedModel, + related_name='pointer', + null=True, + on_delete=models.CASCADE + ) + + +class WithDefaultValue(models.Model): + foo = models.CharField(max_length=20, default='') + + +class WithPassword(models.Model): + pw = models.CharField(max_length=128) + + WITHFILE_UPLOAD_TO = 'django' WITHFILE_UPLOAD_DIR = os.path.join(settings.MEDIA_ROOT, WITHFILE_UPLOAD_TO) + class WithFile(models.Model): afile = models.FileField(upload_to=WITHFILE_UPLOAD_TO) @@ -66,7 +92,48 @@ class WithFile(models.Model): class WithImage(models.Model): animage = models.ImageField(upload_to=WITHFILE_UPLOAD_TO) + size = models.IntegerField(default=0) else: class WithImage(models.Model): pass + + +class WithSignals(models.Model): + foo = models.CharField(max_length=20) + + def __init__(self, post_save_signal_receiver=None): + super().__init__() + if post_save_signal_receiver: + signals.post_save.connect( + post_save_signal_receiver, + sender=self.__class__, + ) + + +class CustomManager(models.Manager): + + def create(self, arg=None, **kwargs): + return super().create(**kwargs) + + +class WithCustomManager(models.Model): + + foo = models.CharField(max_length=20) + + objects = CustomManager() + + +class AbstractWithCustomManager(models.Model): + custom_objects = CustomManager() + + class Meta: + abstract = True + + +class FromAbstractWithCustomManager(AbstractWithCustomManager): + pass + + +class HasMultifieldModel(models.Model): + multifield = models.ForeignKey(to=MultifieldModel, on_delete=models.CASCADE) diff --git a/tests/djapp/settings.py b/tests/djapp/settings.py index c1b79b0a..13f7d366 100644 --- a/tests/djapp/settings.py +++ b/tests/djapp/settings.py @@ -1,23 +1,5 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. + """Settings for factory_boy/Django tests.""" import os @@ -34,6 +16,9 @@ 'default': { 'ENGINE': 'django.db.backends.sqlite3', }, + 'replica': { + 'ENGINE': 'django.db.backends.sqlite3', + }, } @@ -41,5 +26,10 @@ 'tests.djapp' ] +MIDDLEWARE_CLASSES = () SECRET_KEY = 'testing.' + +# TODO: Will be the default after Django 5.0. Remove this setting when +# Django 5.0 is the last supported version. +USE_TZ = True diff --git a/tests/test_alchemy.py b/tests/test_alchemy.py index 4255417a..6b568ce4 100644 --- a/tests/test_alchemy.py +++ b/tests/test_alchemy.py @@ -1,72 +1,93 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2013 Romain Command& -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. """Tests for factory_boy/SQLAlchemy interactions.""" -import factory -from .compat import unittest - +import unittest +from unittest import mock try: import sqlalchemy except ImportError: - sqlalchemy = None - -if sqlalchemy: - from factory.alchemy import SQLAlchemyModelFactory - from .alchemyapp import models -else: + raise unittest.SkipTest("sqlalchemy tests disabled.") - class Fake(object): - FACTORY_SESSION = None +import factory +from factory.alchemy import SQLAlchemyModelFactory - models = Fake() - models.StandardModel = Fake() - models.NonIntegerPk = Fake() - models.session = Fake() - SQLAlchemyModelFactory = Fake +from .alchemyapp import models class StandardFactory(SQLAlchemyModelFactory): - FACTORY_FOR = models.StandardModel - FACTORY_SESSION = models.session + class Meta: + model = models.StandardModel + sqlalchemy_session = models.session id = factory.Sequence(lambda n: n) foo = factory.Sequence(lambda n: 'foo%d' % n) class NonIntegerPkFactory(SQLAlchemyModelFactory): - FACTORY_FOR = models.NonIntegerPk - FACTORY_SESSION = models.session + class Meta: + model = models.NonIntegerPk + sqlalchemy_session = models.session id = factory.Sequence(lambda n: 'foo%d' % n) -@unittest.skipIf(sqlalchemy is None, "SQLalchemy not installed.") -class SQLAlchemyPkSequenceTestCase(unittest.TestCase): +class NoSessionFactory(SQLAlchemyModelFactory): + class Meta: + model = models.StandardModel + sqlalchemy_session = None + + id = factory.Sequence(lambda n: n) + + +class MultifieldModelFactory(SQLAlchemyModelFactory): + class Meta: + model = models.MultiFieldModel + sqlalchemy_get_or_create = ('slug',) + sqlalchemy_session = models.session + sqlalchemy_session_persistence = 'commit' + + id = factory.Sequence(lambda n: n) + foo = factory.Sequence(lambda n: 'foo%d' % n) + + +class WithGetOrCreateFieldFactory(SQLAlchemyModelFactory): + class Meta: + model = models.StandardModel + sqlalchemy_get_or_create = ('foo',) + sqlalchemy_session = models.session + sqlalchemy_session_persistence = 'commit' + + id = factory.Sequence(lambda n: n) + foo = factory.Sequence(lambda n: 'foo%d' % n) + + +class WithMultipleGetOrCreateFieldsFactory(SQLAlchemyModelFactory): + class Meta: + model = models.MultifieldUniqueModel + sqlalchemy_get_or_create = ("slug", "text",) + sqlalchemy_session = models.session + sqlalchemy_session_persistence = 'commit' + + id = factory.Sequence(lambda n: n) + slug = factory.Sequence(lambda n: "slug%s" % n) + text = factory.Sequence(lambda n: "text%s" % n) + +class TransactionTestCase(unittest.TestCase): def setUp(self): - super(SQLAlchemyPkSequenceTestCase, self).setUp() + models.Base.metadata.create_all(models.engine) + + def tearDown(self): + models.session.remove() + models.Base.metadata.drop_all(models.engine) + + +class SQLAlchemyPkSequenceTestCase(TransactionTestCase): + def setUp(self): + super().setUp() StandardFactory.reset_sequence(1) - NonIntegerPkFactory.FACTORY_SESSION.rollback() def test_pk_first(self): std = StandardFactory.build() @@ -85,45 +106,144 @@ def test_pk_creation(self): StandardFactory.reset_sequence() std2 = StandardFactory.create() - self.assertEqual('foo2', std2.foo) - self.assertEqual(2, std2.id) + self.assertEqual('foo0', std2.foo) + self.assertEqual(0, std2.id) def test_pk_force_value(self): std1 = StandardFactory.create(id=10) - self.assertEqual('foo1', std1.foo) # sequence was set before pk + self.assertEqual('foo1', std1.foo) # sequence and pk are unrelated self.assertEqual(10, std1.id) StandardFactory.reset_sequence() std2 = StandardFactory.create() - self.assertEqual('foo11', std2.foo) - self.assertEqual(11, std2.id) + self.assertEqual('foo0', std2.foo) # Sequence doesn't care about pk + self.assertEqual(0, std2.id) + + +class SQLAlchemyGetOrCreateTests(TransactionTestCase): + def test_simple_call(self): + obj1 = WithGetOrCreateFieldFactory(foo='foo1') + obj2 = WithGetOrCreateFieldFactory(foo='foo1') + self.assertEqual(obj1, obj2) + + def test_missing_arg(self): + with self.assertRaises(factory.FactoryError): + MultifieldModelFactory() + + def test_raises_exception_when_existing_objs(self): + StandardFactory.create_batch(2, foo='foo') + with self.assertRaises(sqlalchemy.orm.exc.MultipleResultsFound): + WithGetOrCreateFieldFactory(foo='foo') + + def test_multicall(self): + objs = MultifieldModelFactory.create_batch( + 6, + slug=factory.Iterator(['main', 'alt']), + ) + self.assertEqual(6, len(objs)) + self.assertEqual(2, len(set(objs))) + self.assertEqual( + list( + obj.slug for obj in models.session.query( + models.MultiFieldModel.slug + ).order_by(models.MultiFieldModel.slug) + ), + ["alt", "main"], + ) + + +class MultipleGetOrCreateFieldsTest(TransactionTestCase): + def test_one_defined(self): + obj1 = WithMultipleGetOrCreateFieldsFactory() + obj2 = WithMultipleGetOrCreateFieldsFactory(slug=obj1.slug) + self.assertEqual(obj1, obj2) + + def test_both_defined(self): + obj1 = WithMultipleGetOrCreateFieldsFactory() + with self.assertRaises(sqlalchemy.exc.IntegrityError): + WithMultipleGetOrCreateFieldsFactory(slug=obj1.slug, text="alt") + def test_unique_field_not_in_get_or_create(self): + WithMultipleGetOrCreateFieldsFactory(title='Title') + with self.assertRaises(sqlalchemy.exc.IntegrityError): + WithMultipleGetOrCreateFieldsFactory(title='Title') -@unittest.skipIf(sqlalchemy is None, "SQLalchemy not installed.") -class SQLAlchemyNonIntegerPkTestCase(unittest.TestCase): + +class SQLAlchemySessionPersistenceTestCase(unittest.TestCase): def setUp(self): - super(SQLAlchemyNonIntegerPkTestCase, self).setUp() + super().setUp() + self.mock_session = mock.NonCallableMagicMock(spec=models.session) + + def test_flushing(self): + class FlushingPersistenceFactory(StandardFactory): + class Meta: + sqlalchemy_session = self.mock_session + sqlalchemy_session_persistence = 'flush' + + self.mock_session.commit.assert_not_called() + self.mock_session.flush.assert_not_called() + + FlushingPersistenceFactory.create() + self.mock_session.commit.assert_not_called() + self.mock_session.flush.assert_called_once_with() + + def test_committing(self): + class CommittingPersistenceFactory(StandardFactory): + class Meta: + sqlalchemy_session = self.mock_session + sqlalchemy_session_persistence = 'commit' + + self.mock_session.commit.assert_not_called() + self.mock_session.flush.assert_not_called() + + CommittingPersistenceFactory.create() + self.mock_session.commit.assert_called_once_with() + self.mock_session.flush.assert_not_called() + + def test_noflush_nocommit(self): + class InactivePersistenceFactory(StandardFactory): + class Meta: + sqlalchemy_session = self.mock_session + sqlalchemy_session_persistence = None + + self.mock_session.commit.assert_not_called() + self.mock_session.flush.assert_not_called() + + InactivePersistenceFactory.create() + self.mock_session.commit.assert_not_called() + self.mock_session.flush.assert_not_called() + + def test_type_error(self): + with self.assertRaises(TypeError): + class BadPersistenceFactory(StandardFactory): + class Meta: + sqlalchemy_session_persistence = 'invalid_persistence_option' + model = models.StandardModel + + +class SQLAlchemyNonIntegerPkTestCase(TransactionTestCase): + def tearDown(self): + super().tearDown() NonIntegerPkFactory.reset_sequence() - NonIntegerPkFactory.FACTORY_SESSION.rollback() def test_first(self): nonint = NonIntegerPkFactory.build() - self.assertEqual('foo1', nonint.id) + self.assertEqual('foo0', nonint.id) def test_many(self): nonint1 = NonIntegerPkFactory.build() nonint2 = NonIntegerPkFactory.build() - self.assertEqual('foo1', nonint1.id) - self.assertEqual('foo2', nonint2.id) + self.assertEqual('foo0', nonint1.id) + self.assertEqual('foo1', nonint2.id) def test_creation(self): nonint1 = NonIntegerPkFactory.create() - self.assertEqual('foo1', nonint1.id) + self.assertEqual('foo0', nonint1.id) NonIntegerPkFactory.reset_sequence() nonint2 = NonIntegerPkFactory.build() - self.assertEqual('foo1', nonint2.id) + self.assertEqual('foo0', nonint2.id) def test_force_pk(self): nonint1 = NonIntegerPkFactory.create(id='foo10') @@ -131,4 +251,75 @@ def test_force_pk(self): NonIntegerPkFactory.reset_sequence() nonint2 = NonIntegerPkFactory.create() - self.assertEqual('foo1', nonint2.id) + self.assertEqual('foo0', nonint2.id) + + +class SQLAlchemyNoSessionTestCase(TransactionTestCase): + + def test_create_raises_exception_when_no_session_was_set(self): + with self.assertRaises(RuntimeError): + NoSessionFactory.create() + + def test_build_does_not_raises_exception_when_no_session_was_set(self): + NoSessionFactory.reset_sequence() # Make sure we start at test ID 0 + inst0 = NoSessionFactory.build() + inst1 = NoSessionFactory.build() + self.assertEqual(inst0.id, 0) + self.assertEqual(inst1.id, 1) + + +class SQLAlchemySessionFactoryTestCase(TransactionTestCase): + def test_create_get_session_from_sqlalchemy_session_factory(self): + class SessionGetterFactory(SQLAlchemyModelFactory): + class Meta: + model = models.StandardModel + sqlalchemy_session_factory = lambda: models.session + + id = factory.Sequence(lambda n: n) + + SessionGetterFactory.create() + self.assertEqual(SessionGetterFactory._meta.sqlalchemy_session, models.session) + # Reuse the session obtained from sqlalchemy_session_factory. + SessionGetterFactory.create() + + def test_create_raise_exception_sqlalchemy_session_factory_not_callable(self): + message = "^Provide either a sqlalchemy_session or a sqlalchemy_session_factory, not both$" + with self.assertRaisesRegex(RuntimeError, message): + class SessionAndGetterFactory(SQLAlchemyModelFactory): + class Meta: + model = models.StandardModel + sqlalchemy_session = models.session + sqlalchemy_session_factory = lambda: models.session + + id = factory.Sequence(lambda n: n) + + +class NameConflictTests(TransactionTestCase): + """Regression test for `TypeError: _save() got multiple values for argument 'session'` + + See #775. + """ + def test_no_name_conflict_on_save(self): + class SpecialFieldWithSaveFactory(SQLAlchemyModelFactory): + class Meta: + model = models.SpecialFieldModel + sqlalchemy_session = models.session + + id = factory.Sequence(lambda n: n) + session = '' + + saved_child = SpecialFieldWithSaveFactory() + self.assertEqual(saved_child.session, "") + + def test_no_name_conflict_on_get_or_create(self): + class SpecialFieldWithGetOrCreateFactory(SQLAlchemyModelFactory): + class Meta: + model = models.SpecialFieldModel + sqlalchemy_get_or_create = ('session',) + sqlalchemy_session = models.session + + id = factory.Sequence(lambda n: n) + session = '' + + get_or_created_child = SpecialFieldWithGetOrCreateFactory() + self.assertEqual(get_or_created_child.session, "") diff --git a/tests/test_base.py b/tests/test_base.py index 8cea6fc4..d3b32570 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,33 +1,11 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -import warnings - -from factory import base -from factory import declarations - -from .compat import unittest - -class TestObject(object): +# Copyright: See the LICENSE file. + +import unittest + +from factory import base, declarations, enums, errors + + +class TestObject: def __init__(self, one=None, two=None, three=None, four=None): self.one = one self.two = two @@ -35,7 +13,7 @@ def __init__(self, one=None, two=None, three=None, four=None): self.four = four -class FakeDjangoModel(object): +class FakeDjangoModel: @classmethod def create(cls, **kwargs): instance = cls(**kwargs) @@ -49,11 +27,12 @@ def __init__(self, **kwargs): class FakeModelFactory(base.Factory): - ABSTRACT_FACTORY = True + class Meta: + abstract = True @classmethod - def _create(cls, target_class, *args, **kwargs): - return target_class.create(**kwargs) + def _create(cls, model_class, *args, **kwargs): + return model_class.create(**kwargs) class TestModel(FakeDjangoModel): @@ -62,65 +41,236 @@ class TestModel(FakeDjangoModel): class SafetyTestCase(unittest.TestCase): def test_base_factory(self): - self.assertRaises(base.FactoryError, base.BaseFactory) + with self.assertRaises(errors.FactoryError): + base.BaseFactory() class AbstractFactoryTestCase(unittest.TestCase): def test_factory_for_optional(self): - """Ensure that FACTORY_FOR is optional for ABSTRACT_FACTORY.""" + """Ensure that model= is optional for abstract=True.""" class TestObjectFactory(base.Factory): - ABSTRACT_FACTORY = True + class Meta: + abstract = True - # Passed + self.assertTrue(TestObjectFactory._meta.abstract) + self.assertIsNone(TestObjectFactory._meta.model) def test_factory_for_and_abstract_factory_optional(self): - """Ensure that ABSTRACT_FACTORY is optional.""" + """Ensure that Meta.abstract is optional.""" class TestObjectFactory(base.Factory): pass - # passed + self.assertTrue(TestObjectFactory._meta.abstract) + self.assertIsNone(TestObjectFactory._meta.model) def test_abstract_factory_cannot_be_called(self): class TestObjectFactory(base.Factory): pass - self.assertRaises(base.FactoryError, TestObjectFactory.build) - self.assertRaises(base.FactoryError, TestObjectFactory.create) + with self.assertRaises(errors.FactoryError): + TestObjectFactory.build() + with self.assertRaises(errors.FactoryError): + TestObjectFactory.create() + + def test_abstract_factory_not_inherited(self): + """abstract=True isn't propagated to child classes.""" + + class TestObjectFactory(base.Factory): + class Meta: + abstract = True + model = TestObject + + class TestObjectChildFactory(TestObjectFactory): + pass + + self.assertFalse(TestObjectChildFactory._meta.abstract) + + def test_abstract_or_model_is_required(self): + class TestObjectFactory(base.Factory): + class Meta: + abstract = False + model = None + + with self.assertRaises(errors.FactoryError): + TestObjectFactory.build() + with self.assertRaises(errors.FactoryError): + TestObjectFactory.create() + + +class OptionsTests(unittest.TestCase): + def test_base_attrs(self): + class AbstractFactory(base.Factory): + pass + + # Declarative attributes + self.assertTrue(AbstractFactory._meta.abstract) + self.assertIsNone(AbstractFactory._meta.model) + self.assertEqual((), AbstractFactory._meta.inline_args) + self.assertEqual((), AbstractFactory._meta.exclude) + self.assertEqual(enums.CREATE_STRATEGY, AbstractFactory._meta.strategy) + + # Non-declarative attributes + self.assertEqual({}, AbstractFactory._meta.pre_declarations.as_dict()) + self.assertEqual({}, AbstractFactory._meta.post_declarations.as_dict()) + self.assertEqual(AbstractFactory, AbstractFactory._meta.factory) + self.assertEqual(base.Factory, AbstractFactory._meta.base_factory) + self.assertEqual(AbstractFactory._meta, AbstractFactory._meta.counter_reference) + + def test_declaration_collecting(self): + lazy = declarations.LazyFunction(int) + lazy2 = declarations.LazyAttribute(lambda _o: 1) + postgen = declarations.PostGenerationDeclaration() + + class AbstractFactory(base.Factory): + x = 1 + y = lazy + y2 = lazy2 + z = postgen + + # Declarations aren't removed + self.assertEqual(1, AbstractFactory.x) + self.assertEqual(lazy, AbstractFactory.y) + self.assertEqual(lazy2, AbstractFactory.y2) + self.assertEqual(postgen, AbstractFactory.z) + + # And are available in class Meta + self.assertEqual( + {'x': 1, 'y': lazy, 'y2': lazy2}, + AbstractFactory._meta.pre_declarations.as_dict(), + ) + self.assertEqual( + {'z': postgen}, + AbstractFactory._meta.post_declarations.as_dict(), + ) + + def test_inherited_declaration_collecting(self): + lazy = declarations.LazyFunction(int) + lazy2 = declarations.LazyAttribute(lambda _o: 2) + postgen = declarations.PostGenerationDeclaration() + postgen2 = declarations.PostGenerationDeclaration() + + class AbstractFactory(base.Factory): + x = 1 + y = lazy + z = postgen + + class OtherFactory(AbstractFactory): + a = lazy2 + b = postgen2 + + # Declarations aren't removed + self.assertEqual(lazy2, OtherFactory.a) + self.assertEqual(postgen2, OtherFactory.b) + self.assertEqual(1, OtherFactory.x) + self.assertEqual(lazy, OtherFactory.y) + self.assertEqual(postgen, OtherFactory.z) + + # And are available in class Meta + self.assertEqual( + {'x': 1, 'y': lazy, 'a': lazy2}, + OtherFactory._meta.pre_declarations.as_dict(), + ) + self.assertEqual( + {'z': postgen, 'b': postgen2}, + OtherFactory._meta.post_declarations.as_dict(), + ) + + def test_inherited_declaration_shadowing(self): + lazy = declarations.LazyFunction(int) + lazy2 = declarations.LazyAttribute(lambda _o: 2) + postgen = declarations.PostGenerationDeclaration() + postgen2 = declarations.PostGenerationDeclaration() + + class AbstractFactory(base.Factory): + x = 1 + y = lazy + z = postgen + + class OtherFactory(AbstractFactory): + y = lazy2 + z = postgen2 + + # Declarations aren't removed + self.assertEqual(1, OtherFactory.x) + self.assertEqual(lazy2, OtherFactory.y) + self.assertEqual(postgen2, OtherFactory.z) + + # And are available in class Meta + self.assertEqual( + {'x': 1, 'y': lazy2}, + OtherFactory._meta.pre_declarations.as_dict(), + ) + self.assertEqual( + {'z': postgen2}, + OtherFactory._meta.post_declarations.as_dict(), + ) + + def test_factory_as_meta_model_raises_exception(self): + class FirstFactory(base.Factory): + pass + + class Meta: + model = FirstFactory + + with self.assertRaises(TypeError): + type("SecondFactory", (base.Factory,), {"Meta": Meta}) + + +class DeclarationParsingTests(unittest.TestCase): + def test_classmethod(self): + class TestObjectFactory(base.Factory): + class Meta: + model = TestObject + + @classmethod + def some_classmethod(cls): + return cls.create() + + self.assertTrue(hasattr(TestObjectFactory, 'some_classmethod')) + obj = TestObjectFactory.some_classmethod() + self.assertEqual(TestObject, obj.__class__) class FactoryTestCase(unittest.TestCase): - def test_factory_for(self): + def test_magic_happens(self): + """Calling a FooFactory doesn't yield a FooFactory instance.""" class TestObjectFactory(base.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject - self.assertEqual(TestObject, TestObjectFactory.FACTORY_FOR) + self.assertEqual(TestObject, TestObjectFactory._meta.model) obj = TestObjectFactory.build() - self.assertFalse(hasattr(obj, 'FACTORY_FOR')) + self.assertFalse(hasattr(obj, '_meta')) def test_display(self): class TestObjectFactory(base.Factory): - FACTORY_FOR = FakeDjangoModel + class Meta: + model = FakeDjangoModel self.assertIn('TestObjectFactory', str(TestObjectFactory)) self.assertIn('FakeDjangoModel', str(TestObjectFactory)) def test_lazy_attribute_non_existent_param(self): class TestObjectFactory(base.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject - one = declarations.LazyAttribute(lambda a: a.does_not_exist ) + one = declarations.LazyAttribute(lambda a: a.does_not_exist) - self.assertRaises(AttributeError, TestObjectFactory) + with self.assertRaises(AttributeError): + TestObjectFactory() def test_inheritance_with_sequence(self): """Tests that sequence IDs are shared between parent and son.""" class TestObjectFactory(base.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = declarations.Sequence(lambda a: a) class TestSubFactory(TestObjectFactory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject pass @@ -128,16 +278,17 @@ class TestSubFactory(TestObjectFactory): sub = TestSubFactory.build() alt_parent = TestObjectFactory.build() alt_sub = TestSubFactory.build() - ones = set([x.one for x in (parent, alt_parent, sub, alt_sub)]) + ones = {x.one for x in (parent, alt_parent, sub, alt_sub)} self.assertEqual(4, len(ones)) class FactorySequenceTestCase(unittest.TestCase): def setUp(self): - super(FactorySequenceTestCase, self).setUp() + super().setUp() class TestObjectFactory(base.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = declarations.Sequence(lambda n: n) self.TestObjectFactory = TestObjectFactory @@ -165,11 +316,12 @@ def test_reset_sequence_with_value(self): self.assertEqual(42, o3.one) def test_reset_sequence_subclass_fails(self): - """Tests that the sequence of a 'slave' factory cannot be reseted.""" + """Tests that the sequence of a 'slave' factory cannot be reset.""" class SubTestObjectFactory(self.TestObjectFactory): pass - self.assertRaises(ValueError, SubTestObjectFactory.reset_sequence) + with self.assertRaises(ValueError): + SubTestObjectFactory.reset_sequence() def test_reset_sequence_subclass_force(self): """Tests that reset_sequence(force=True) works.""" @@ -191,7 +343,7 @@ class SubTestObjectFactory(self.TestObjectFactory): self.assertEqual(1, o4.one) def test_reset_sequence_subclass_parent(self): - """Tests that the sequence of a 'slave' factory cannot be reseted.""" + """Tests that the sequence of a 'slave' factory cannot be reset.""" class SubTestObjectFactory(self.TestObjectFactory): pass @@ -209,19 +361,12 @@ class SubTestObjectFactory(self.TestObjectFactory): self.assertEqual(1, o4.one) - class FactoryDefaultStrategyTestCase(unittest.TestCase): - def setUp(self): - self.default_strategy = base.Factory.FACTORY_STRATEGY - - def tearDown(self): - base.Factory.FACTORY_STRATEGY = self.default_strategy - def test_build_strategy(self): - base.Factory.FACTORY_STRATEGY = base.BUILD_STRATEGY - class TestModelFactory(base.Factory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel + strategy = enums.BUILD_STRATEGY one = 'one' @@ -230,10 +375,11 @@ class TestModelFactory(base.Factory): self.assertFalse(test_model.id) def test_create_strategy(self): - # Default FACTORY_STRATEGY + # Default Meta.strategy class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 'one' @@ -242,10 +388,10 @@ class TestModelFactory(FakeModelFactory): self.assertTrue(test_model.id) def test_stub_strategy(self): - base.Factory.FACTORY_STRATEGY = base.STUB_STRATEGY - class TestModelFactory(base.Factory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel + strategy = enums.STUB_STRATEGY one = 'one' @@ -254,42 +400,56 @@ class TestModelFactory(base.Factory): self.assertFalse(hasattr(test_model, 'id')) # We should have a plain old object def test_unknown_strategy(self): - base.Factory.FACTORY_STRATEGY = 'unknown' - class TestModelFactory(base.Factory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel + strategy = 'unknown' one = 'one' - self.assertRaises(base.Factory.UnknownStrategy, TestModelFactory) + with self.assertRaises(base.Factory.UnknownStrategy): + TestModelFactory() - def test_stub_with_non_stub_strategy(self): + def test_stub_with_create_strategy(self): class TestModelFactory(base.StubFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel + strategy = enums.CREATE_STRATEGY one = 'one' - TestModelFactory.FACTORY_STRATEGY = base.CREATE_STRATEGY + with self.assertRaises(base.StubFactory.UnsupportedStrategy): + TestModelFactory() - self.assertRaises(base.StubFactory.UnsupportedStrategy, TestModelFactory) + def test_stub_with_build_strategy(self): + class TestModelFactory(base.StubFactory): + class Meta: + model = TestModel + strategy = enums.BUILD_STRATEGY - TestModelFactory.FACTORY_STRATEGY = base.BUILD_STRATEGY - self.assertRaises(base.StubFactory.UnsupportedStrategy, TestModelFactory) + one = 'one' + + obj = TestModelFactory() + + # For stubs, build() is an alias of stub(). + self.assertFalse(isinstance(obj, TestModel)) def test_change_strategy(self): - @base.use_strategy(base.CREATE_STRATEGY) class TestModelFactory(base.StubFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel + strategy = enums.CREATE_STRATEGY one = 'one' - self.assertEqual(base.CREATE_STRATEGY, TestModelFactory.FACTORY_STRATEGY) + self.assertEqual(enums.CREATE_STRATEGY, TestModelFactory._meta.strategy) class FactoryCreationTestCase(unittest.TestCase): def test_factory_for(self): class TestFactory(base.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject self.assertTrue(isinstance(TestFactory.build(), TestObject)) @@ -297,27 +457,46 @@ def test_stub(self): class TestFactory(base.StubFactory): pass - self.assertEqual(TestFactory.FACTORY_STRATEGY, base.STUB_STRATEGY) + self.assertEqual(TestFactory._meta.strategy, enums.STUB_STRATEGY) def test_inheritance_with_stub(self): class TestObjectFactory(base.StubFactory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject pass class TestFactory(TestObjectFactory): pass - self.assertEqual(TestFactory.FACTORY_STRATEGY, base.STUB_STRATEGY) + self.assertEqual(TestFactory._meta.strategy, enums.STUB_STRATEGY) + + def test_stub_and_subfactory(self): + class StubA(base.StubFactory): + class Meta: + model = TestObject + + one = 'blah' + + class StubB(base.StubFactory): + class Meta: + model = TestObject + + stubbed = declarations.SubFactory(StubA, two='two') + + b = StubB() + self.assertEqual('blah', b.stubbed.one) + self.assertEqual('two', b.stubbed.two) def test_custom_creation(self): class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel @classmethod - def _prepare(cls, create, **kwargs): - kwargs['four'] = 4 - return super(TestModelFactory, cls)._prepare(create, **kwargs) + def _generate(cls, create, attrs): + attrs['four'] = 4 + return super()._generate(create, attrs) b = TestModelFactory.build(one=1) self.assertEqual(1, b.one) @@ -335,30 +514,27 @@ def test_no_associated_class(self): class Test(base.Factory): pass - self.assertTrue(Test._abstract_factory) + self.assertTrue(Test._meta.abstract) class PostGenerationParsingTestCase(unittest.TestCase): def test_extraction(self): class TestObjectFactory(base.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject foo = declarations.PostGenerationDeclaration() - self.assertIn('foo', TestObjectFactory._postgen_declarations) + self.assertIn('foo', TestObjectFactory._meta.post_declarations.as_dict()) def test_classlevel_extraction(self): class TestObjectFactory(base.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject foo = declarations.PostGenerationDeclaration() foo__bar = 42 - self.assertIn('foo', TestObjectFactory._postgen_declarations) - self.assertIn('foo__bar', TestObjectFactory._declarations) - - - -if __name__ == '__main__': # pragma: no cover - unittest.main() + self.assertIn('foo', TestObjectFactory._meta.post_declarations.as_dict()) + self.assertIn('foo__bar', TestObjectFactory._meta.post_declarations.as_dict()) diff --git a/tests/test_containers.py b/tests/test_containers.py deleted file mode 100644 index 8b78dc77..00000000 --- a/tests/test_containers.py +++ /dev/null @@ -1,363 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -from factory import base -from factory import containers -from factory import declarations - -from .compat import unittest - -class LazyStubTestCase(unittest.TestCase): - def test_basic(self): - stub = containers.LazyStub({'one': 1, 'two': 2}) - - self.assertEqual({'one': 1, 'two': 2}, stub.__fill__()) - - def test_setting_values(self): - stub = containers.LazyStub({'one': 1, 'two': 2}) - - self.assertRaises(AttributeError, setattr, stub, 'one', 1) - - def test_reading_value(self): - stub = containers.LazyStub({'one': 1, 'two': 2}) - self.assertEqual(1, stub.one) - - self.assertRaises(AttributeError, getattr, stub, 'three') - - def test_accessing_container(self): - class LazyAttr(containers.LazyValue): - def __init__(self, obj_attr, container_attr): - self.obj_attr = obj_attr - self.container_attr = container_attr - - def evaluate(self, obj, containers=()): - if containers: - add = getattr(containers[0], self.container_attr) - else: - add = 0 - return getattr(obj, self.obj_attr) + add - - class DummyContainer(object): - three = 3 - - stub = containers.LazyStub({'one': LazyAttr('two', 'three'), 'two': 2, 'three': 42}, - containers=(DummyContainer(),)) - - self.assertEqual(5, stub.one) - - stub = containers.LazyStub({'one': LazyAttr('two', 'three'), 'two': 2, 'three': 42}, - containers=()) - self.assertEqual(2, stub.one) - - def test_access_parent(self): - """Test simple access to a stub' parent.""" - o1 = containers.LazyStub({'rank': 1}) - o2 = containers.LazyStub({'rank': 2}, (o1,)) - stub = containers.LazyStub({'rank': 3}, (o2, o1)) - - self.assertEqual(3, stub.rank) - self.assertEqual(2, stub.factory_parent.rank) - self.assertEqual(1, stub.factory_parent.factory_parent.rank) - - def test_cyclic_definition(self): - class LazyAttr(containers.LazyValue): - def __init__(self, attrname): - self.attrname = attrname - - def evaluate(self, obj, container=None): - return 1 + getattr(obj, self.attrname) - - stub = containers.LazyStub({'one': LazyAttr('two'), 'two': LazyAttr('one')}) - - self.assertRaises(containers.CyclicDefinitionError, getattr, stub, 'one') - - def test_representation(self): - class RandomObj(object): - pass - - stub = containers.LazyStub({'one': 1, 'two': 2}, target_class=RandomObj) - self.assertIn('RandomObj', repr(stub)) - self.assertIn('RandomObj', str(stub)) - self.assertIn('one', str(stub)) - - -class OrderedDeclarationMock(declarations.OrderedDeclaration): - pass - - -class DeclarationDictTestCase(unittest.TestCase): - def test_basics(self): - one = OrderedDeclarationMock() - two = 2 - three = OrderedDeclarationMock() - - d = containers.DeclarationDict(dict(one=one, two=two, three=three)) - - self.assertTrue('one' in d) - self.assertTrue('two' in d) - self.assertTrue('three' in d) - - self.assertEqual(one, d['one']) - self.assertEqual(two, d['two']) - self.assertEqual(three, d['three']) - - self.assertEqual(one, d.pop('one')) - self.assertFalse('one' in d) - - d['one'] = one - self.assertTrue('one' in d) - self.assertEqual(one, d['one']) - - self.assertEqual(set(['one', 'two', 'three']), - set(d)) - - def test_insert(self): - one = OrderedDeclarationMock() - two = 2 - three = OrderedDeclarationMock() - four = OrderedDeclarationMock() - - d = containers.DeclarationDict(dict(one=one, two=two, four=four)) - - self.assertEqual(set(['two', 'one', 'four']), set(d)) - - d['three'] = three - self.assertEqual(set(['two', 'one', 'three', 'four']), set(d)) - - def test_replace(self): - one = OrderedDeclarationMock() - two = 2 - three = OrderedDeclarationMock() - four = OrderedDeclarationMock() - - d = containers.DeclarationDict(dict(one=one, two=two, three=three)) - - self.assertEqual(set(['two', 'one', 'three']), set(d)) - - d['three'] = four - self.assertEqual(set(['two', 'one', 'three']), set(d)) - self.assertEqual(set([two, one, four]), set(d.values())) - - def test_copy(self): - one = OrderedDeclarationMock() - two = 2 - three = OrderedDeclarationMock() - four = OrderedDeclarationMock() - - d = containers.DeclarationDict(dict(one=one, two=two, three=three)) - d2 = d.copy({'five': 5}) - - self.assertEqual(5, d2['five']) - self.assertFalse('five' in d) - - d.pop('one') - self.assertEqual(one, d2['one']) - - d2['two'] = four - self.assertEqual(four, d2['two']) - self.assertEqual(two, d['two']) - - def test_update_with_public(self): - d = containers.DeclarationDict() - d.update_with_public({ - 'one': 1, - '_two': 2, - 'three': 3, - 'classmethod': classmethod(lambda c: 1), - 'staticmethod': staticmethod(lambda: 1), - }) - self.assertEqual(set(['one', 'three']), set(d)) - self.assertEqual(set([1, 3]), set(d.values())) - - def test_update_with_public_ignores_factory_attributes(self): - """Ensure that a DeclarationDict ignores FACTORY_ keys.""" - d = containers.DeclarationDict() - d.update_with_public({ - 'one': 1, - 'FACTORY_FOR': 2, - 'FACTORY_ARG_PARAMETERS': 3, - }) - self.assertEqual(['one'], list(d)) - self.assertEqual([1], list(d.values())) - - -class AttributeBuilderTestCase(unittest.TestCase): - def test_empty(self): - """Tests building attributes from an empty definition.""" - - class FakeFactory(object): - @classmethod - def declarations(cls, extra): - return extra - - @classmethod - def _generate_next_sequence(cls): - return 1 - - ab = containers.AttributeBuilder(FakeFactory) - - self.assertEqual({}, ab.build(create=False)) - - def test_factory_defined(self): - class FakeFactory(object): - @classmethod - def declarations(cls, extra): - d = {'one': 1} - d.update(extra) - return d - - @classmethod - def _generate_next_sequence(cls): - return 1 - - ab = containers.AttributeBuilder(FakeFactory) - self.assertEqual({'one': 1}, ab.build(create=False)) - - def test_extended(self): - class FakeFactory(object): - @classmethod - def declarations(cls, extra): - d = {'one': 1} - d.update(extra) - return d - - @classmethod - def _generate_next_sequence(cls): - return 1 - - ab = containers.AttributeBuilder(FakeFactory, {'two': 2}) - self.assertEqual({'one': 1, 'two': 2}, ab.build(create=False)) - - def test_overridden(self): - class FakeFactory(object): - @classmethod - def declarations(cls, extra): - d = {'one': 1} - d.update(extra) - return d - - @classmethod - def _generate_next_sequence(cls): - return 1 - - ab = containers.AttributeBuilder(FakeFactory, {'one': 2}) - self.assertEqual({'one': 2}, ab.build(create=False)) - - def test_factory_defined_sequence(self): - seq = declarations.Sequence(lambda n: 'xx%d' % n) - - class FakeFactory(object): - @classmethod - def declarations(cls, extra): - d = {'one': seq} - d.update(extra) - return d - - @classmethod - def _generate_next_sequence(cls): - return 1 - - ab = containers.AttributeBuilder(FakeFactory) - self.assertEqual({'one': 'xx1'}, ab.build(create=False)) - - def test_additionnal_sequence(self): - seq = declarations.Sequence(lambda n: 'xx%d' % n) - - class FakeFactory(object): - @classmethod - def declarations(cls, extra): - d = {'one': 1} - d.update(extra) - return d - - @classmethod - def _generate_next_sequence(cls): - return 1 - - ab = containers.AttributeBuilder(FakeFactory, extra={'two': seq}) - self.assertEqual({'one': 1, 'two': 'xx1'}, ab.build(create=False)) - - def test_replaced_sequence(self): - seq = declarations.Sequence(lambda n: 'xx%d' % n) - seq2 = declarations.Sequence(lambda n: 'yy%d' % n) - - class FakeFactory(object): - @classmethod - def declarations(cls, extra): - d = {'one': seq} - d.update(extra) - return d - - @classmethod - def _generate_next_sequence(cls): - return 1 - - ab = containers.AttributeBuilder(FakeFactory, extra={'one': seq2}) - self.assertEqual({'one': 'yy1'}, ab.build(create=False)) - - def test_lazy_attribute(self): - la = declarations.LazyAttribute(lambda a: a.one * 2) - - class FakeFactory(object): - @classmethod - def declarations(cls, extra): - d = containers.DeclarationDict({'one': 1, 'two': la}) - d.update(extra) - return d - - @classmethod - def _generate_next_sequence(cls): - return 1 - - ab = containers.AttributeBuilder(FakeFactory) - self.assertEqual({'one': 1, 'two': 2}, ab.build(create=False)) - - ab = containers.AttributeBuilder(FakeFactory, {'one': 4}) - self.assertEqual({'one': 4, 'two': 8}, ab.build(create=False)) - - ab = containers.AttributeBuilder(FakeFactory, {'one': 4, 'three': la}) - self.assertEqual({'one': 4, 'two': 8, 'three': 8}, ab.build(create=False)) - - def test_subfields(self): - class FakeInnerFactory(object): - pass - - sf = declarations.SubFactory(FakeInnerFactory) - - class FakeFactory(object): - @classmethod - def declarations(cls, extra): - d = {'one': sf, 'two': 2} - d.update(extra) - return d - - ab = containers.AttributeBuilder(FakeFactory, {'one__blah': 1, 'two__bar': 2}) - self.assertTrue(ab.has_subfields(sf)) - self.assertEqual(['one'], list(ab._subfields.keys())) - self.assertEqual(2, ab._attrs['two__bar']) - - def test_sub_factory(self): - pass - - -if __name__ == '__main__': # pragma: no cover - unittest.main() diff --git a/tests/test_declarations.py b/tests/test_declarations.py index 86bc8b50..c49bbba7 100644 --- a/tests/test_declarations.py +++ b/tests/test_declarations.py @@ -1,44 +1,22 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. import datetime -import itertools -import warnings +import unittest +from unittest import mock -from factory import declarations -from factory import helpers +from factory import base, declarations, errors, helpers -from .compat import mock, unittest -from . import tools +from . import utils class OrderedDeclarationTestCase(unittest.TestCase): def test_errors(self): - decl = declarations.OrderedDeclaration() - self.assertRaises(NotImplementedError, decl.evaluate, None, {}, False) + with self.assertRaises(NotImplementedError): + utils.evaluate_declaration(declarations.OrderedDeclaration()) class DigTestCase(unittest.TestCase): - class MyObj(object): + class MyObj: def __init__(self, n): self.n = n @@ -49,11 +27,14 @@ def test_chaining(self): obj.a.b.c = self.MyObj(4) self.assertEqual(2, declarations.deepgetattr(obj, 'a').n) - self.assertRaises(AttributeError, declarations.deepgetattr, obj, 'b') + with self.assertRaises(AttributeError): + declarations.deepgetattr(obj, 'b') self.assertEqual(2, declarations.deepgetattr(obj, 'a.n')) self.assertEqual(3, declarations.deepgetattr(obj, 'a.c', 3)) - self.assertRaises(AttributeError, declarations.deepgetattr, obj, 'a.c.n') - self.assertRaises(AttributeError, declarations.deepgetattr, obj, 'a.d') + with self.assertRaises(AttributeError): + declarations.deepgetattr(obj, 'a.c.n') + with self.assertRaises(AttributeError): + declarations.deepgetattr(obj, 'a.d') self.assertEqual(3, declarations.deepgetattr(obj, 'a.b').n) self.assertEqual(3, declarations.deepgetattr(obj, 'a.b.n')) self.assertEqual(4, declarations.deepgetattr(obj, 'a.b.c').n) @@ -61,6 +42,14 @@ def test_chaining(self): self.assertEqual(42, declarations.deepgetattr(obj, 'a.b.c.n.x', 42)) +class MaybeTestCase(unittest.TestCase): + def test_init(self): + declarations.Maybe('foo', 1, 2) + + with self.assertRaisesRegex(TypeError, 'Inconsistent phases'): + declarations.Maybe('foo', declarations.LazyAttribute(None), declarations.PostGenerationDeclaration()) + + class SelfAttributeTestCase(unittest.TestCase): def test_standard(self): a = declarations.SelfAttribute('foo.bar.baz') @@ -96,80 +85,106 @@ def test_grandparent(self): class IteratorTestCase(unittest.TestCase): def test_cycle(self): it = declarations.Iterator([1, 2]) - self.assertEqual(1, it.evaluate(0, None, False)) - self.assertEqual(2, it.evaluate(1, None, False)) - self.assertEqual(1, it.evaluate(2, None, False)) - self.assertEqual(2, it.evaluate(3, None, False)) + self.assertEqual(1, utils.evaluate_declaration(it, force_sequence=0)) + self.assertEqual(2, utils.evaluate_declaration(it, force_sequence=1)) + self.assertEqual(1, utils.evaluate_declaration(it, force_sequence=2)) + self.assertEqual(2, utils.evaluate_declaration(it, force_sequence=3)) def test_no_cycling(self): it = declarations.Iterator([1, 2], cycle=False) - self.assertEqual(1, it.evaluate(0, None, False)) - self.assertEqual(2, it.evaluate(1, None, False)) - self.assertRaises(StopIteration, it.evaluate, 2, None, False) + self.assertEqual(1, utils.evaluate_declaration(it, force_sequence=0)) + self.assertEqual(2, utils.evaluate_declaration(it, force_sequence=1)) + with self.assertRaises(StopIteration): + utils.evaluate_declaration(it, force_sequence=2) + + def test_initial_reset(self): + it = declarations.Iterator([1, 2]) + it.reset() def test_reset_cycle(self): it = declarations.Iterator([1, 2]) - self.assertEqual(1, it.evaluate(0, None, False)) - self.assertEqual(2, it.evaluate(1, None, False)) - self.assertEqual(1, it.evaluate(2, None, False)) - self.assertEqual(2, it.evaluate(3, None, False)) - self.assertEqual(1, it.evaluate(4, None, False)) + self.assertEqual(1, utils.evaluate_declaration(it, force_sequence=0)) + self.assertEqual(2, utils.evaluate_declaration(it, force_sequence=1)) + self.assertEqual(1, utils.evaluate_declaration(it, force_sequence=2)) + self.assertEqual(2, utils.evaluate_declaration(it, force_sequence=3)) + self.assertEqual(1, utils.evaluate_declaration(it, force_sequence=4)) it.reset() - self.assertEqual(1, it.evaluate(5, None, False)) - self.assertEqual(2, it.evaluate(6, None, False)) + self.assertEqual(1, utils.evaluate_declaration(it, force_sequence=5)) + self.assertEqual(2, utils.evaluate_declaration(it, force_sequence=6)) def test_reset_no_cycling(self): it = declarations.Iterator([1, 2], cycle=False) - self.assertEqual(1, it.evaluate(0, None, False)) - self.assertEqual(2, it.evaluate(1, None, False)) - self.assertRaises(StopIteration, it.evaluate, 2, None, False) + self.assertEqual(1, utils.evaluate_declaration(it, force_sequence=0)) + self.assertEqual(2, utils.evaluate_declaration(it, force_sequence=1)) + with self.assertRaises(StopIteration): + utils.evaluate_declaration(it, force_sequence=2) it.reset() - self.assertEqual(1, it.evaluate(0, None, False)) - self.assertEqual(2, it.evaluate(1, None, False)) - self.assertRaises(StopIteration, it.evaluate, 2, None, False) + self.assertEqual(1, utils.evaluate_declaration(it, force_sequence=0)) + self.assertEqual(2, utils.evaluate_declaration(it, force_sequence=1)) + with self.assertRaises(StopIteration): + utils.evaluate_declaration(it, force_sequence=2) def test_getter(self): it = declarations.Iterator([(1, 2), (1, 3)], getter=lambda p: p[1]) - self.assertEqual(2, it.evaluate(0, None, False)) - self.assertEqual(3, it.evaluate(1, None, False)) - self.assertEqual(2, it.evaluate(2, None, False)) - self.assertEqual(3, it.evaluate(3, None, False)) + self.assertEqual(2, utils.evaluate_declaration(it, force_sequence=0)) + self.assertEqual(3, utils.evaluate_declaration(it, force_sequence=1)) + self.assertEqual(2, utils.evaluate_declaration(it, force_sequence=2)) + self.assertEqual(3, utils.evaluate_declaration(it, force_sequence=3)) + + +class TransformerTestCase(unittest.TestCase): + def test_transform(self): + t = declarations.Transformer('foo', transform=str.upper) + self.assertEqual("FOO", utils.evaluate_declaration(t)) class PostGenerationDeclarationTestCase(unittest.TestCase): - def test_extract_no_prefix(self): - decl = declarations.PostGenerationDeclaration() + def test_post_generation(self): + call_params = [] - context = decl.extract('foo', - {'foo': 13, 'foo__bar': 42}) - self.assertTrue(context.did_extract) - self.assertEqual(context.value, 13) - self.assertEqual(context.extra, {'bar': 42}) + def foo(*args, **kwargs): + call_params.append(args) + call_params.append(kwargs) + + helpers.build( + dict, + foo=declarations.PostGeneration(foo), + foo__bar=42, + blah=42, + blah__baz=1, + ) + + self.assertEqual(2, len(call_params)) + self.assertEqual(3, len(call_params[0])) # instance, step, context.value + self.assertEqual({'bar': 42}, call_params[1]) def test_decorator_simple(self): call_params = [] + @helpers.post_generation def foo(*args, **kwargs): call_params.append(args) call_params.append(kwargs) - context = foo.extract('foo', - {'foo': 13, 'foo__bar': 42, 'blah': 42, 'blah__baz': 1}) - self.assertTrue(context.did_extract) - self.assertEqual(13, context.value) - self.assertEqual({'bar': 42}, context.extra) + helpers.build( + dict, + foo=foo, + foo__bar=42, + blah=42, + blah__baz=1, + ) - # No value returned. - foo.call(None, False, context) self.assertEqual(2, len(call_params)) - self.assertEqual((None, False, 13), call_params[0]) + self.assertEqual(3, len(call_params[0])) # instance, step, context.value self.assertEqual({'bar': 42}, call_params[1]) class FactoryWrapperTestCase(unittest.TestCase): def test_invalid_path(self): - self.assertRaises(ValueError, declarations._FactoryWrapper, 'UnqualifiedSymbol') - self.assertRaises(ValueError, declarations._FactoryWrapper, 42) + with self.assertRaises(ValueError): + declarations._FactoryWrapper('UnqualifiedSymbol') + with self.assertRaises(ValueError): + declarations._FactoryWrapper(42) def test_class(self): w = declarations._FactoryWrapper(datetime.date) @@ -207,94 +222,95 @@ def test_cache(self): datetime.date = orig_date -class RelatedFactoryTestCase(unittest.TestCase): - - def test_deprecate_name(self): - with warnings.catch_warnings(record=True) as w: - - warnings.simplefilter('always') - f = declarations.RelatedFactory('datetime.date', name='blah') - - self.assertEqual('blah', f.name) - self.assertEqual(1, len(w)) - self.assertIn('RelatedFactory', str(w[0].message)) - self.assertIn('factory_related_name', str(w[0].message)) - - class PostGenerationMethodCallTestCase(unittest.TestCase): - def setUp(self): - self.obj = mock.MagicMock() - - def ctx(self, value=None, force_value=False, extra=None): - return declarations.ExtractionContext( - value, - bool(value) or force_value, - extra, - ) + def build(self, declaration, **params): + f = helpers.make_factory(mock.MagicMock, post=declaration) + return f(**params) def test_simplest_setup_and_call(self): - decl = declarations.PostGenerationMethodCall('method') - decl.call(self.obj, False, self.ctx()) - self.obj.method.assert_called_once_with() + obj = self.build( + declarations.PostGenerationMethodCall('method'), + ) + obj.method.assert_called_once_with() def test_call_with_method_args(self): - decl = declarations.PostGenerationMethodCall( - 'method', 'data') - decl.call(self.obj, False, self.ctx()) - self.obj.method.assert_called_once_with('data') + obj = self.build( + declarations.PostGenerationMethodCall('method', 'data'), + ) + obj.method.assert_called_once_with('data') def test_call_with_passed_extracted_string(self): - decl = declarations.PostGenerationMethodCall( - 'method') - decl.call(self.obj, False, self.ctx('data')) - self.obj.method.assert_called_once_with('data') + obj = self.build( + declarations.PostGenerationMethodCall('method'), + post='data', + ) + obj.method.assert_called_once_with('data') def test_call_with_passed_extracted_int(self): - decl = declarations.PostGenerationMethodCall('method') - decl.call(self.obj, False, self.ctx(1)) - self.obj.method.assert_called_once_with(1) + obj = self.build( + declarations.PostGenerationMethodCall('method'), + post=1, + ) + obj.method.assert_called_once_with(1) def test_call_with_passed_extracted_iterable(self): - decl = declarations.PostGenerationMethodCall('method') - decl.call(self.obj, False, self.ctx((1, 2, 3))) - self.obj.method.assert_called_once_with((1, 2, 3)) + obj = self.build( + declarations.PostGenerationMethodCall('method'), + post=(1, 2, 3), + ) + obj.method.assert_called_once_with((1, 2, 3)) def test_call_with_method_kwargs(self): - decl = declarations.PostGenerationMethodCall( - 'method', data='data') - decl.call(self.obj, False, self.ctx()) - self.obj.method.assert_called_once_with(data='data') + obj = self.build( + declarations.PostGenerationMethodCall('method', data='data'), + ) + obj.method.assert_called_once_with(data='data') def test_call_with_passed_kwargs(self): - decl = declarations.PostGenerationMethodCall('method') - decl.call(self.obj, False, self.ctx(extra={'data': 'other'})) - self.obj.method.assert_called_once_with(data='other') + obj = self.build( + declarations.PostGenerationMethodCall('method'), + post__data='other', + ) + obj.method.assert_called_once_with(data='other') def test_multi_call_with_multi_method_args(self): - decl = declarations.PostGenerationMethodCall( - 'method', 'arg1', 'arg2') - decl.call(self.obj, False, self.ctx()) - self.obj.method.assert_called_once_with('arg1', 'arg2') + with self.assertRaises(errors.InvalidDeclarationError): + self.build( + declarations.PostGenerationMethodCall('method', 'arg1', 'arg2'), + ) + + +class PostGenerationOrdering(unittest.TestCase): + + def test_post_generation_declaration_order(self): + postgen_results = [] + + class Related(base.Factory): + class Meta: + model = mock.MagicMock() + + class Ordered(base.Factory): + class Meta: + model = mock.MagicMock() - def test_multi_call_with_passed_multiple_args(self): - decl = declarations.PostGenerationMethodCall( - 'method', 'arg1', 'arg2') - decl.call(self.obj, False, self.ctx(('param1', 'param2', 'param3'))) - self.obj.method.assert_called_once_with('param1', 'param2', 'param3') + a = declarations.RelatedFactory(Related) + z = declarations.RelatedFactory(Related) - def test_multi_call_with_passed_tuple(self): - decl = declarations.PostGenerationMethodCall( - 'method', 'arg1', 'arg2') - decl.call(self.obj, False, self.ctx((('param1', 'param2'),))) - self.obj.method.assert_called_once_with(('param1', 'param2')) + @helpers.post_generation + def a1(*args, **kwargs): + postgen_results.append('a1') - def test_multi_call_with_kwargs(self): - decl = declarations.PostGenerationMethodCall( - 'method', 'arg1', 'arg2') - decl.call(self.obj, False, self.ctx(extra={'x': 2})) - self.obj.method.assert_called_once_with('arg1', 'arg2', x=2) + @helpers.post_generation + def zz(*args, **kwargs): + postgen_results.append('zz') + @helpers.post_generation + def aa(*args, **kwargs): + postgen_results.append('aa') + postgen_names = Ordered._meta.post_declarations.sorted() + self.assertEqual(postgen_names, ['a', 'z', 'a1', 'zz', 'aa']) -if __name__ == '__main__': # pragma: no cover - unittest.main() + # Test generation happens in desired order + Ordered() + self.assertEqual(postgen_results, ['a1', 'zz', 'aa']) diff --git a/tests/test_dev_experience.py b/tests/test_dev_experience.py new file mode 100644 index 00000000..6677b08c --- /dev/null +++ b/tests/test_dev_experience.py @@ -0,0 +1,58 @@ +# Copyright: See the LICENSE file. + +"""Tests about developer experience: help messages, errors, etc.""" + +import collections +import unittest + +import factory +import factory.errors + +Country = collections.namedtuple('Country', ['name', 'continent', 'capital_city']) +City = collections.namedtuple('City', ['name', 'population']) + + +class DeclarationTests(unittest.TestCase): + def test_subfactory_to_model(self): + """A helpful error message occurs when pointing a subfactory to a model.""" + class CountryFactory(factory.Factory): + class Meta: + model = Country + + name = factory.Faker('country') + continent = "Antarctica" + + # Error here: pointing the SubFactory to a model, not a factory. + capital_city = factory.SubFactory(City) + + with self.assertRaises(factory.errors.AssociatedClassError) as raised: + CountryFactory() + + self.assertIn('City', str(raised.exception)) + self.assertIn('Country', str(raised.exception)) + + def test_subfactory_to_factorylike_model(self): + """A helpful error message occurs when pointing a subfactory to a model. + + This time with a model that looks more like a factory (ie has a `._meta`).""" + + class CityModel: + _meta = None + name = "Coruscant" + population = 0 + + class CountryFactory(factory.Factory): + class Meta: + model = Country + + name = factory.Faker('country') + continent = "Antarctica" + + # Error here: pointing the SubFactory to a model, not a factory. + capital_city = factory.SubFactory(CityModel) + + with self.assertRaises(factory.errors.AssociatedClassError) as raised: + CountryFactory() + + self.assertIn('CityModel', str(raised.exception)) + self.assertIn('Country', str(raised.exception)) diff --git a/tests/test_django.py b/tests/test_django.py index 94101e92..066d7920 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -1,198 +1,293 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. + """Tests for factory_boy/Django interactions.""" +import io import os - -import factory -import factory.django - +import unittest +from unittest import mock try: import django -except ImportError: # pragma: no cover - django = None +except ImportError: + raise unittest.SkipTest("django tests disabled.") -try: - from PIL import Image -except ImportError: # pragma: no cover - # Try PIL alternate name - try: - import Image - except ImportError: - # OK, not installed - Image = None +from django import test as django_test +from django.conf import settings +from django.contrib.auth.hashers import check_password +from django.core.management import color +from django.db import IntegrityError, connections +from django.db.models import signals +from django.test import utils as django_test_utils +import factory +import factory.django -from .compat import is_python2, unittest from . import testdata -from . import tools - -if django is not None: - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.djapp.settings') - - from django import test as django_test - from django.conf import settings - from django.db import models as django_models - from django.test import simple as django_test_simple - from django.test import utils as django_test_utils - from .djapp import models -else: # pragma: no cover - django_test = unittest - - class Fake(object): - pass +try: + from PIL import Image +except ImportError: + Image = None - models = Fake() - models.StandardModel = Fake - models.AbstractBase = Fake - models.ConcreteSon = Fake - models.NonIntegerPk = Fake - models.WithFile = Fake - models.WithImage = Fake +# Setup Django before importing Django models. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.djapp.settings') +django.setup() +from .djapp import models # noqa:E402 isort:skip test_state = {} def setUpModule(): - if django is None: # pragma: no cover - raise unittest.SkipTest("Django not installed") django_test_utils.setup_test_environment() - runner = django_test_simple.DjangoTestSuiteRunner() - runner_state = runner.setup_databases() - test_state.update({ - 'runner': runner, - 'runner_state': runner_state, - }) + runner_state = django_test_utils.setup_databases(verbosity=0, interactive=False) + test_state['runner_state'] = runner_state def tearDownModule(): - if django is None: # pragma: no cover - return - runner = test_state['runner'] - runner_state = test_state['runner_state'] - runner.teardown_databases(runner_state) + django_test_utils.teardown_databases(test_state['runner_state'], verbosity=0) django_test_utils.teardown_test_environment() class StandardFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.StandardModel + class Meta: + model = models.StandardModel foo = factory.Sequence(lambda n: "foo%d" % n) class StandardFactoryWithPKField(factory.django.DjangoModelFactory): - FACTORY_FOR = models.StandardModel - FACTORY_DJANGO_GET_OR_CREATE = ('pk',) + class Meta: + model = models.StandardModel + django_get_or_create = ('pk',) foo = factory.Sequence(lambda n: "foo%d" % n) pk = None class NonIntegerPkFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.NonIntegerPk + class Meta: + model = models.NonIntegerPk foo = factory.Sequence(lambda n: "foo%d" % n) bar = '' +class MultifieldModelFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.MultifieldModel + django_get_or_create = ['slug'] + + text = factory.Faker('text') + + class AbstractBaseFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.AbstractBase - ABSTRACT_FACTORY = True + class Meta: + model = models.AbstractBase + abstract = True foo = factory.Sequence(lambda n: "foo%d" % n) class ConcreteSonFactory(AbstractBaseFactory): - FACTORY_FOR = models.ConcreteSon + class Meta: + model = models.ConcreteSon + + +class AbstractSonFactory(AbstractBaseFactory): + class Meta: + model = models.AbstractSon + + +class ConcreteGrandSonFactory(AbstractBaseFactory): + class Meta: + model = models.ConcreteGrandSon + + +PASSWORD = 's0_s3cr3t' + + +class WithPasswordFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.WithPassword + + pw = factory.django.Password(password=PASSWORD) class WithFileFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.WithFile + class Meta: + model = models.WithFile - if django is not None: - afile = factory.django.FileField() + afile = factory.django.FileField() class WithImageFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = models.WithImage + class Meta: + model = models.WithImage - if django is not None: - animage = factory.django.ImageField() + animage = factory.django.ImageField() -@unittest.skipIf(django is None, "Django not installed.") -class DjangoPkSequenceTestCase(django_test.TestCase): +class WithSignalsFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.WithSignals + + +class WithCustomManagerFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.WithCustomManager + + foo = factory.Sequence(lambda n: "foo%d" % n) + + +class WithMultipleGetOrCreateFieldsFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.MultifieldUniqueModel + django_get_or_create = ("slug", "text",) + + slug = factory.Sequence(lambda n: "slug%s" % n) + text = factory.Sequence(lambda n: "text%s" % n) + + +class ModelTests(django_test.TestCase): + databases = {'default', 'replica'} + + def test_unset_model(self): + class UnsetModelFactory(factory.django.DjangoModelFactory): + pass + + with self.assertRaises(factory.FactoryError): + UnsetModelFactory.create() + + def test_cross_database(self): + class OtherDBFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.StandardModel + database = 'replica' + + obj = OtherDBFactory() + self.assertFalse(models.StandardModel.objects.exists()) + self.assertEqual(obj, models.StandardModel.objects.using('replica').get()) + + +class DjangoResetTestCase(django_test.TestCase): + def reset_database_sequences(self, *models): + using = factory.django.DEFAULT_DB_ALIAS + with connections[using].cursor() as cursor: + sequence_sql = connections[using].ops.sequence_reset_sql(color.no_style(), models) + for command in sequence_sql: + cursor.execute(command) + + +class DjangoPkSequenceTestCase(DjangoResetTestCase): def setUp(self): - super(DjangoPkSequenceTestCase, self).setUp() + super().setUp() StandardFactory.reset_sequence() def test_pk_first(self): std = StandardFactory.build() - self.assertEqual('foo1', std.foo) + self.assertEqual('foo0', std.foo) def test_pk_many(self): std1 = StandardFactory.build() std2 = StandardFactory.build() - self.assertEqual('foo1', std1.foo) - self.assertEqual('foo2', std2.foo) + self.assertEqual('foo0', std1.foo) + self.assertEqual('foo1', std2.foo) def test_pk_creation(self): + self.reset_database_sequences(StandardFactory._meta.model) + std1 = StandardFactory.create() - self.assertEqual('foo1', std1.foo) + self.assertEqual('foo0', std1.foo) self.assertEqual(1, std1.pk) StandardFactory.reset_sequence() std2 = StandardFactory.create() - self.assertEqual('foo2', std2.foo) + self.assertEqual('foo0', std2.foo) self.assertEqual(2, std2.pk) def test_pk_force_value(self): std1 = StandardFactory.create(pk=10) - self.assertEqual('foo1', std1.foo) # sequence was set before pk + self.assertEqual('foo0', std1.foo) # sequence is unrelated to pk self.assertEqual(10, std1.pk) + self.reset_database_sequences(StandardFactory._meta.model) + StandardFactory.reset_sequence() std2 = StandardFactory.create() - self.assertEqual('foo11', std2.foo) + self.assertEqual('foo0', std2.foo) self.assertEqual(11, std2.pk) -@unittest.skipIf(django is None, "Django not installed.") +class DjangoGetOrCreateTests(django_test.TestCase): + def test_simple_call(self): + obj1 = MultifieldModelFactory(slug='slug1') + obj2 = MultifieldModelFactory(slug='slug1') + MultifieldModelFactory(slug='alt') + + self.assertEqual(obj1, obj2) + self.assertEqual( + list( + models.MultifieldModel.objects.order_by("slug").values_list( + "slug", flat=True + ) + ), + ["alt", "slug1"], + ) + + def test_missing_arg(self): + with self.assertRaises(factory.FactoryError): + MultifieldModelFactory() + + def test_multicall(self): + objs = MultifieldModelFactory.create_batch( + 6, + slug=factory.Iterator(['main', 'alt']), + ) + self.assertEqual(6, len(objs)) + self.assertEqual(2, len(set(objs))) + self.assertEqual( + list( + models.MultifieldModel.objects.order_by("slug").values_list( + "slug", flat=True + ) + ), + ["alt", "main"], + ) + + +class MultipleGetOrCreateFieldsTest(django_test.TestCase): + def test_one_defined(self): + obj1 = WithMultipleGetOrCreateFieldsFactory() + obj2 = WithMultipleGetOrCreateFieldsFactory(slug=obj1.slug) + self.assertEqual(obj1, obj2) + + def test_both_defined(self): + obj1 = WithMultipleGetOrCreateFieldsFactory() + with self.assertRaises(django.db.IntegrityError): + WithMultipleGetOrCreateFieldsFactory(slug=obj1.slug, text="alt") + + def test_unique_field_not_in_get_or_create(self): + WithMultipleGetOrCreateFieldsFactory(title="Title") + with self.assertRaises(django.db.IntegrityError): + WithMultipleGetOrCreateFieldsFactory(title="Title") + + class DjangoPkForceTestCase(django_test.TestCase): def setUp(self): - super(DjangoPkForceTestCase, self).setUp() + super().setUp() StandardFactoryWithPKField.reset_sequence() def test_no_pk(self): std = StandardFactoryWithPKField() self.assertIsNotNone(std.pk) - self.assertEqual('foo1', std.foo) + self.assertEqual('foo0', std.foo) def test_force_pk(self): std = StandardFactoryWithPKField(pk=42) self.assertIsNotNone(std.pk) - self.assertEqual('foo1', std.foo) + self.assertEqual('foo0', std.foo) def test_reuse_pk(self): std1 = StandardFactoryWithPKField(foo='bar') @@ -203,60 +298,90 @@ def test_reuse_pk(self): self.assertEqual('bar', std2.foo) -@unittest.skipIf(django is None, "Django not installed.") class DjangoModelLoadingTestCase(django_test.TestCase): - """Tests FACTORY_FOR = 'app.Model' pattern.""" + """Tests class Meta: + model = 'app.Model' pattern.""" def test_loading(self): - class ExampleFactory(factory.DjangoModelFactory): - FACTORY_FOR = 'djapp.StandardModel' + class ExampleFactory(factory.django.DjangoModelFactory): + class Meta: + model = 'djapp.StandardModel' - self.assertEqual(models.StandardModel, ExampleFactory._load_target_class()) + self.assertEqual(models.StandardModel, ExampleFactory._meta.get_model_class()) def test_building(self): - class ExampleFactory(factory.DjangoModelFactory): - FACTORY_FOR = 'djapp.StandardModel' + class ExampleFactory(factory.django.DjangoModelFactory): + class Meta: + model = 'djapp.StandardModel' e = ExampleFactory.build() self.assertEqual(models.StandardModel, e.__class__) - def test_cache(self): - class ExampleFactory(factory.DjangoModelFactory): - FACTORY_FOR = 'djapp.StandardModel' + def test_inherited_loading(self): + """Proper loading of a model within 'child' factories. + + See https://github.com/FactoryBoy/factory_boy/issues/109. + """ + class ExampleFactory(factory.django.DjangoModelFactory): + class Meta: + model = 'djapp.StandardModel' - self.assertEqual('djapp.StandardModel', ExampleFactory._associated_class) - self.assertIsNone(ExampleFactory._associated_model) + class Example2Factory(ExampleFactory): + pass - self.assertEqual(models.StandardModel, ExampleFactory._load_target_class()) - self.assertEqual('djapp.StandardModel', ExampleFactory._associated_class) - self.assertEqual(models.StandardModel, ExampleFactory._associated_model) + e = Example2Factory.build() + self.assertEqual(models.StandardModel, e.__class__) + + def test_inherited_loading_and_sequence(self): + """Proper loading of a model within 'child' factories. + + See https://github.com/FactoryBoy/factory_boy/issues/109. + """ + class ExampleFactory(factory.django.DjangoModelFactory): + class Meta: + model = 'djapp.StandardModel' + + foo = factory.Sequence(lambda n: n) + + class Example2Factory(ExampleFactory): + class Meta: + model = 'djapp.StandardSon' + + e1 = ExampleFactory.build() + e2 = Example2Factory.build() + e3 = ExampleFactory.build() + self.assertEqual(models.StandardModel, e1.__class__) + self.assertEqual(models.StandardSon, e2.__class__) + self.assertEqual(models.StandardModel, e3.__class__) + self.assertEqual(0, e1.foo) + self.assertEqual(1, e2.foo) + self.assertEqual(2, e3.foo) -@unittest.skipIf(django is None, "Django not installed.") class DjangoNonIntegerPkTestCase(django_test.TestCase): def setUp(self): - super(DjangoNonIntegerPkTestCase, self).setUp() + super().setUp() NonIntegerPkFactory.reset_sequence() def test_first(self): nonint = NonIntegerPkFactory.build() - self.assertEqual('foo1', nonint.foo) + self.assertEqual('foo0', nonint.foo) def test_many(self): nonint1 = NonIntegerPkFactory.build() nonint2 = NonIntegerPkFactory.build() - self.assertEqual('foo1', nonint1.foo) - self.assertEqual('foo2', nonint2.foo) + self.assertEqual('foo0', nonint1.foo) + self.assertEqual('foo1', nonint2.foo) def test_creation(self): nonint1 = NonIntegerPkFactory.create() - self.assertEqual('foo1', nonint1.foo) - self.assertEqual('foo1', nonint1.pk) + self.assertEqual('foo0', nonint1.foo) + self.assertEqual('foo0', nonint1.pk) NonIntegerPkFactory.reset_sequence() nonint2 = NonIntegerPkFactory.build() - self.assertEqual('foo1', nonint2.foo) + self.assertEqual('foo0', nonint2.foo) def test_force_pk(self): nonint1 = NonIntegerPkFactory.create(pk='foo10') @@ -265,23 +390,166 @@ def test_force_pk(self): NonIntegerPkFactory.reset_sequence() nonint2 = NonIntegerPkFactory.create() - self.assertEqual('foo1', nonint2.foo) - self.assertEqual('foo1', nonint2.pk) + self.assertEqual('foo0', nonint2.foo) + self.assertEqual('foo0', nonint2.pk) + + +class DjangoAbstractBaseSequenceTestCase(DjangoResetTestCase): + def test_auto_sequence_son(self): + """The sequence of the concrete son of an abstract model should be autonomous.""" + obj = ConcreteSonFactory() + self.assertEqual(1, obj.pk) + + def test_auto_sequence_grandson(self): + """The sequence of the concrete grandson of an abstract model should be autonomous.""" + obj = ConcreteGrandSonFactory() + self.assertEqual(1, obj.pk) + + def test_optional_abstract(self): + """Users need not describe the factory for an abstract model as abstract.""" + class AbstractBaseFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.AbstractBase + + foo = factory.Sequence(lambda n: "foo%d" % n) + class ConcreteSonFactory(AbstractBaseFactory): + class Meta: + model = models.ConcreteSon -@unittest.skipIf(django is None, "Django not installed.") -class DjangoAbstractBaseSequenceTestCase(django_test.TestCase): - def test_auto_sequence(self): - with factory.debug(): - obj = ConcreteSonFactory() + self.reset_database_sequences(models.ConcreteSon) + + obj = ConcreteSonFactory() self.assertEqual(1, obj.pk) + self.assertEqual("foo0", obj.foo) + + +class DjangoRelatedFieldTestCase(django_test.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() -@unittest.skipIf(django is None, "Django not installed.") -class DjangoFileFieldTestCase(unittest.TestCase): + class PointedFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.PointedModel + foo = 'foo' + + class PointerFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.PointerModel + bar = 'bar' + pointed = factory.SubFactory(PointedFactory, foo='new_foo') + + class PointedRelatedFactory(PointedFactory): + pointer = factory.RelatedFactory( + PointerFactory, + factory_related_name='pointed', + ) + + class Meta: + skip_postgeneration_save = True + + class PointerExtraFactory(PointerFactory): + pointed__foo = 'extra_new_foo' + + class PointedRelatedExtraFactory(PointedRelatedFactory): + pointer__bar = 'extra_new_bar' + + class PointedRelatedWithTraitFactory(PointedFactory): + class Params: + with_pointer = factory.Trait( + pointer=factory.RelatedFactory( + PointerFactory, + factory_related_name='pointed', + bar='with_trait', + ) + ) + + class Meta: + skip_postgeneration_save = True + + cls.PointedFactory = PointedFactory + cls.PointerFactory = PointerFactory + cls.PointedRelatedFactory = PointedRelatedFactory + cls.PointerExtraFactory = PointerExtraFactory + cls.PointedRelatedExtraFactory = PointedRelatedExtraFactory + cls.PointedRelatedWithTraitFactory = PointedRelatedWithTraitFactory + + def test_create_pointed(self): + pointed = self.PointedFactory() + self.assertEqual(pointed, models.PointedModel.objects.get()) + self.assertEqual(pointed.foo, 'foo') + + def test_create_pointer(self): + pointer = self.PointerFactory() + self.assertEqual(pointer.pointed, models.PointedModel.objects.get()) + self.assertEqual(pointer.pointed.foo, 'new_foo') + + def test_create_pointer_with_deep_context(self): + pointer = self.PointerFactory(pointed__foo='new_new_foo') + self.assertEqual(pointer, models.PointerModel.objects.get()) + self.assertEqual(pointer.bar, 'bar') + self.assertEqual(pointer.pointed, models.PointedModel.objects.get()) + self.assertEqual(pointer.pointed.foo, 'new_new_foo') + + def test_create_pointed_related(self): + pointed = self.PointedRelatedFactory() + self.assertEqual(pointed, models.PointedModel.objects.get()) + self.assertEqual(pointed.foo, 'foo') + self.assertEqual(pointed.pointer, models.PointerModel.objects.get()) + self.assertEqual(pointed.pointer.bar, 'bar') + + def test_create_pointed_related_with_deep_context(self): + pointed = self.PointedRelatedFactory(pointer__bar='new_new_bar') + self.assertEqual(pointed, models.PointedModel.objects.get()) + self.assertEqual(pointed.foo, 'foo') + self.assertEqual(pointed.pointer, models.PointerModel.objects.get()) + self.assertEqual(pointed.pointer.bar, 'new_new_bar') + + def test_create_pointer_extra(self): + pointer = self.PointerExtraFactory() + self.assertEqual(pointer, models.PointerModel.objects.get()) + self.assertEqual(pointer.bar, 'bar') + self.assertEqual(pointer.pointed, models.PointedModel.objects.get()) + self.assertEqual(pointer.pointed.foo, 'extra_new_foo') + + def test_create_pointed_related_extra(self): + pointed = self.PointedRelatedExtraFactory() + self.assertEqual(pointed, models.PointedModel.objects.get()) + self.assertEqual(pointed.foo, 'foo') + self.assertEqual(pointed.pointer, models.PointerModel.objects.get()) + self.assertEqual(pointed.pointer.bar, 'extra_new_bar') + + def test_create_pointed_related_with_trait(self): + pointed = self.PointedRelatedWithTraitFactory( + with_pointer=True + ) + self.assertEqual(pointed, models.PointedModel.objects.get()) + self.assertEqual(pointed.foo, 'foo') + self.assertEqual(pointed.pointer, models.PointerModel.objects.get()) + self.assertEqual(pointed.pointer.bar, 'with_trait') + + +class DjangoPasswordTestCase(django_test.TestCase): + def test_build(self): + u = WithPasswordFactory.build() + self.assertTrue(check_password(PASSWORD, u.pw)) + + def test_build_with_kwargs(self): + password = 'V3R¥.S€C®€T' + u = WithPasswordFactory.build(pw=password) + self.assertTrue(check_password(password, u.pw)) + + def test_create(self): + u = WithPasswordFactory.create() + self.assertTrue(check_password(PASSWORD, u.pw)) + + +class DjangoFileFieldTestCase(django_test.TestCase): def tearDown(self): - super(DjangoFileFieldTestCase, self).tearDown() + super().tearDown() for path in os.listdir(models.WITHFILE_UPLOAD_DIR): # Remove temporary files written during tests. os.unlink(os.path.join(models.WITHFILE_UPLOAD_DIR, path)) @@ -290,31 +558,46 @@ def test_default_build(self): o = WithFileFactory.build() self.assertIsNone(o.pk) self.assertEqual(b'', o.afile.read()) + self.assertEqual('example.dat', o.afile.name) + + o.save() self.assertEqual('django/example.dat', o.afile.name) def test_default_create(self): o = WithFileFactory.create() self.assertIsNotNone(o.pk) - self.assertEqual(b'', o.afile.read()) + with o.afile as f: + self.assertEqual(b'', f.read()) self.assertEqual('django/example.dat', o.afile.name) def test_with_content(self): o = WithFileFactory.build(afile__data='foo') self.assertIsNone(o.pk) - self.assertEqual(b'foo', o.afile.read()) + + # Django only allocates the full path on save() + o.save() + with o.afile as f: + self.assertEqual(b'foo', f.read()) self.assertEqual('django/example.dat', o.afile.name) def test_with_file(self): with open(testdata.TESTFILE_PATH, 'rb') as f: o = WithFileFactory.build(afile__from_file=f) - self.assertIsNone(o.pk) - self.assertEqual(b'example_data\n', o.afile.read()) + o.save() + + with o.afile as f: + self.assertEqual(b'example_data\n', f.read()) self.assertEqual('django/example.data', o.afile.name) def test_with_path(self): o = WithFileFactory.build(afile__from_path=testdata.TESTFILE_PATH) self.assertIsNone(o.pk) - self.assertEqual(b'example_data\n', o.afile.read()) + + with o.afile as f: + # Django only allocates the full path on save() + o.save() + f.seek(0) + self.assertEqual(b'example_data\n', f.read()) self.assertEqual('django/example.data', o.afile.name) def test_with_file_empty_path(self): @@ -323,8 +606,11 @@ def test_with_file_empty_path(self): afile__from_file=f, afile__from_path='' ) - self.assertIsNone(o.pk) - self.assertEqual(b'example_data\n', o.afile.read()) + # Django only allocates the full path on save() + o.save() + + with o.afile as f: + self.assertEqual(b'example_data\n', f.read()) self.assertEqual('django/example.data', o.afile.name) def test_with_path_empty_file(self): @@ -333,14 +619,20 @@ def test_with_path_empty_file(self): afile__from_file=None, ) self.assertIsNone(o.pk) - self.assertEqual(b'example_data\n', o.afile.read()) + + with o.afile as f: + # Django only allocates the full path on save() + o.save() + f.seek(0) + self.assertEqual(b'example_data\n', f.read()) self.assertEqual('django/example.data', o.afile.name) def test_error_both_file_and_path(self): - self.assertRaises(ValueError, WithFileFactory.build, - afile__from_file='fakefile', - afile__from_path=testdata.TESTFILE_PATH, - ) + with self.assertRaises(ValueError): + WithFileFactory.build( + afile__from_file='fakefile', + afile__from_path=testdata.TESTFILE_PATH, + ) def test_override_filename_with_path(self): o = WithFileFactory.build( @@ -348,16 +640,28 @@ def test_override_filename_with_path(self): afile__filename='example.foo', ) self.assertIsNone(o.pk) - self.assertEqual(b'example_data\n', o.afile.read()) + + with o.afile as f: + # Django only allocates the full path on save() + o.save() + f.seek(0) + self.assertEqual(b'example_data\n', f.read()) self.assertEqual('django/example.foo', o.afile.name) def test_existing_file(self): o1 = WithFileFactory.build(afile__from_path=testdata.TESTFILE_PATH) + with o1.afile: + o1.save() + self.assertEqual('django/example.data', o1.afile.name) - o2 = WithFileFactory.build(afile=o1.afile) + o2 = WithFileFactory.build(afile__from_file=o1.afile) self.assertIsNone(o2.pk) - self.assertEqual(b'example_data\n', o2.afile.read()) - self.assertEqual('django/example_1.data', o2.afile.name) + with o2.afile as f: + o2.save() + f.seek(0) + self.assertEqual(b'example_data\n', f.read()) + self.assertNotEqual('django/example.data', o2.afile.name) + self.assertRegex(o2.afile.name, r'django/example_\w+.data') def test_no_file(self): o = WithFileFactory.build(afile=None) @@ -365,12 +669,64 @@ def test_no_file(self): self.assertFalse(o.afile) -@unittest.skipIf(django is None, "Django not installed.") +class DjangoParamsTestCase(django_test.TestCase): + + def test_undeclared_fields(self): + class WithDefaultValueFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.WithDefaultValue + + class Params: + with_bar = factory.Trait( + foo='bar', + ) + + o = WithDefaultValueFactory() + self.assertEqual('', o.foo) + + def test_pointing_with_traits_using_same_name(self): + class PointedFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.PointedModel + + class Params: + with_bar = factory.Trait( + foo='bar', + ) + + class PointerFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.PointerModel + pointed = factory.SubFactory(PointedFactory) + + class Params: + with_bar = factory.Trait( + bar='bar', + pointed__with_bar=True, + ) + + o = PointerFactory(with_bar=True) + self.assertEqual('bar', o.bar) + self.assertEqual('bar', o.pointed.foo) + + +class DjangoFakerTestCase(django_test.TestCase): + def test_random(self): + class StandardModelFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.StandardModel + foo = factory.Faker('pystr') + + o1 = StandardModelFactory() + o2 = StandardModelFactory() + self.assertNotEqual(o1.foo, o2.foo) + + @unittest.skipIf(Image is None, "PIL not installed.") -class DjangoImageFieldTestCase(unittest.TestCase): +class DjangoImageFieldTestCase(django_test.TestCase): def tearDown(self): - super(DjangoImageFieldTestCase, self).tearDown() + super().tearDown() for path in os.listdir(models.WITHFILE_UPLOAD_DIR): # Remove temporary files written during tests. os.unlink(os.path.join(models.WITHFILE_UPLOAD_DIR, path)) @@ -378,6 +734,8 @@ def tearDown(self): def test_default_build(self): o = WithImageFactory.build() self.assertIsNone(o.pk) + o.save() + self.assertEqual(100, o.animage.width) self.assertEqual(100, o.animage.height) self.assertEqual('django/example.jpg', o.animage.name) @@ -385,49 +743,81 @@ def test_default_build(self): def test_default_create(self): o = WithImageFactory.create() self.assertIsNotNone(o.pk) + o.save() + self.assertEqual(100, o.animage.width) self.assertEqual(100, o.animage.height) self.assertEqual('django/example.jpg', o.animage.name) + def test_complex_create(self): + o = WithImageFactory.create( + size=10, + animage__filename=factory.Sequence(lambda n: 'img%d.jpg' % n), + __sequence=42, + animage__width=factory.SelfAttribute('..size'), + animage__height=factory.SelfAttribute('width'), + ) + self.assertIsNotNone(o.pk) + self.assertEqual('django/img42.jpg', o.animage.name) + def test_with_content(self): o = WithImageFactory.build(animage__width=13, animage__color='red') self.assertIsNone(o.pk) + o.save() + self.assertEqual(13, o.animage.width) self.assertEqual(13, o.animage.height) self.assertEqual('django/example.jpg', o.animage.name) - i = Image.open(os.path.join(settings.MEDIA_ROOT, o.animage.name)) - colors = i.getcolors() + with Image.open(os.path.join(settings.MEDIA_ROOT, o.animage.name)) as i: + colors = i.getcolors() # 169 pixels with rgb(254, 0, 0) self.assertEqual([(169, (254, 0, 0))], colors) self.assertEqual('JPEG', i.format) + def test_rgba_image(self): + o = WithImageFactory.create( + animage__palette='RGBA', + animage__format='PNG', + ) + self.assertIsNotNone(o.pk) + + with Image.open(os.path.join(settings.MEDIA_ROOT, o.animage.name)) as i: + self.assertEqual('RGBA', i.mode) + def test_gif(self): o = WithImageFactory.build(animage__width=13, animage__color='blue', animage__format='GIF') self.assertIsNone(o.pk) + o.save() + self.assertEqual(13, o.animage.width) self.assertEqual(13, o.animage.height) self.assertEqual('django/example.jpg', o.animage.name) - i = Image.open(os.path.join(settings.MEDIA_ROOT, o.animage.name)) - colors = i.getcolors() - # 169 pixels with color 190 from the GIF palette - self.assertEqual([(169, 190)], colors) + with Image.open(os.path.join(settings.MEDIA_ROOT, o.animage.name)) as i: + colors = i.convert('RGB').getcolors() + # 169 pixels with rgb(0, 0, 255) + self.assertEqual([(169, (0, 0, 255))], colors) self.assertEqual('GIF', i.format) def test_with_file(self): with open(testdata.TESTIMAGE_PATH, 'rb') as f: o = WithImageFactory.build(animage__from_file=f) - self.assertIsNone(o.pk) - # Image file for a 42x42 green jpeg: 301 bytes long. - self.assertEqual(301, len(o.animage.read())) + o.save() + + with o.animage as f: + # Image file for a 42x42 green jpeg: 301 bytes long. + self.assertEqual(301, len(f.read())) self.assertEqual('django/example.jpeg', o.animage.name) def test_with_path(self): o = WithImageFactory.build(animage__from_path=testdata.TESTIMAGE_PATH) self.assertIsNone(o.pk) - # Image file for a 42x42 green jpeg: 301 bytes long. - self.assertEqual(301, len(o.animage.read())) + with o.animage as f: + o.save() + f.seek(0) + # Image file for a 42x42 green jpeg: 301 bytes long. + self.assertEqual(301, len(f.read())) self.assertEqual('django/example.jpeg', o.animage.name) def test_with_file_empty_path(self): @@ -436,9 +826,11 @@ def test_with_file_empty_path(self): animage__from_file=f, animage__from_path='' ) - self.assertIsNone(o.pk) - # Image file for a 42x42 green jpeg: 301 bytes long. - self.assertEqual(301, len(o.animage.read())) + o.save() + + with o.animage as f: + # Image file for a 42x42 green jpeg: 301 bytes long. + self.assertEqual(301, len(f.read())) self.assertEqual('django/example.jpeg', o.animage.name) def test_with_path_empty_file(self): @@ -447,15 +839,19 @@ def test_with_path_empty_file(self): animage__from_file=None, ) self.assertIsNone(o.pk) - # Image file for a 42x42 green jpeg: 301 bytes long. - self.assertEqual(301, len(o.animage.read())) + with o.animage as f: + o.save() + f.seek(0) + # Image file for a 42x42 green jpeg: 301 bytes long. + self.assertEqual(301, len(f.read())) self.assertEqual('django/example.jpeg', o.animage.name) def test_error_both_file_and_path(self): - self.assertRaises(ValueError, WithImageFactory.build, - animage__from_file='fakefile', - animage__from_path=testdata.TESTIMAGE_PATH, - ) + with self.assertRaises(ValueError): + WithImageFactory.build( + animage__from_file='fakefile', + animage__from_path=testdata.TESTIMAGE_PATH, + ) def test_override_filename_with_path(self): o = WithImageFactory.build( @@ -463,24 +859,327 @@ def test_override_filename_with_path(self): animage__filename='example.foo', ) self.assertIsNone(o.pk) - # Image file for a 42x42 green jpeg: 301 bytes long. - self.assertEqual(301, len(o.animage.read())) + with o.animage as f: + o.save() + f.seek(0) + # Image file for a 42x42 green jpeg: 301 bytes long. + self.assertEqual(301, len(f.read())) self.assertEqual('django/example.foo', o.animage.name) def test_existing_file(self): o1 = WithImageFactory.build(animage__from_path=testdata.TESTIMAGE_PATH) + o1.save() - o2 = WithImageFactory.build(animage=o1.animage) - self.assertIsNone(o2.pk) - # Image file for a 42x42 green jpeg: 301 bytes long. - self.assertEqual(301, len(o2.animage.read())) - self.assertEqual('django/example_1.jpeg', o2.animage.name) + with o1.animage as f: + o2 = WithImageFactory.build(animage__from_file=f) + self.assertIsNone(o2.pk) + o2.save() + + with o2.animage as f: + # Image file for a 42x42 green jpeg: 301 bytes long. + self.assertEqual(301, len(f.read())) + self.assertNotEqual('django/example.jpeg', o2.animage.name) + self.assertRegex(o2.animage.name, r'django/example_\w+.jpeg') def test_no_file(self): o = WithImageFactory.build(animage=None) self.assertIsNone(o.pk) self.assertFalse(o.animage) + def _img_test_func(self): + img = Image.new('RGB', (32, 32), 'blue') + img_io = io.BytesIO() + img.save(img_io, format='JPEG') + img_io.seek(0) + return img_io + + def test_with_func(self): + o = WithImageFactory.build(animage__from_func=self._img_test_func) + self.assertIsNone(o.pk) + i = Image.open(o.animage.file) + self.assertEqual('JPEG', i.format) + self.assertEqual(32, i.width) + self.assertEqual(32, i.height) + + +class PreventSignalsTestCase(django_test.TestCase): + def setUp(self): + self.handlers = mock.MagicMock() + + signals.pre_init.connect(self.handlers.pre_init) + signals.pre_save.connect(self.handlers.pre_save) + signals.post_save.connect(self.handlers.post_save) + + def tearDown(self): + signals.pre_init.disconnect(self.handlers.pre_init) + signals.pre_save.disconnect(self.handlers.pre_save) + signals.post_save.disconnect(self.handlers.post_save) + + def assertSignalsReactivated(self): + WithSignalsFactory() + + self.assertEqual(self.handlers.pre_save.call_count, 1) + self.assertEqual(self.handlers.post_save.call_count, 1) + + def test_context_manager(self): + with factory.django.mute_signals(signals.pre_save, signals.post_save): + WithSignalsFactory() + + self.assertEqual(self.handlers.pre_init.call_count, 1) + self.assertFalse(self.handlers.pre_save.called) + self.assertFalse(self.handlers.post_save.called) + + self.assertSignalsReactivated() + + def test_receiver_created_during_model_instantiation_is_not_lost(self): + with factory.django.mute_signals(signals.post_save): + instance = WithSignalsFactory(post_save_signal_receiver=self.handlers.created_during_instantiation) + self.assertTrue(self.handlers.created_during_instantiation.called) + + self.handlers.created_during_instantiation.reset_mock() + instance.save() + + self.assertTrue(self.handlers.created_during_instantiation.called) + + def test_signal_receiver_order_restored_after_mute_signals(self): + def must_be_first(*args, **kwargs): + self.handlers.do_stuff(1) + + def must_be_second(*args, **kwargs): + self.handlers.do_stuff(2) + + signals.post_save.connect(must_be_first) + with factory.django.mute_signals(signals.post_save): + WithSignalsFactory(post_save_signal_receiver=must_be_second) + self.assertEqual(self.handlers.do_stuff.call_args_list, [mock.call(2)]) + + self.handlers.reset_mock() + WithSignalsFactory(post_save_signal_receiver=must_be_second) + self.assertEqual(self.handlers.do_stuff.call_args_list, [mock.call(1), mock.call(2)]) + + def test_signal_cache(self): + with factory.django.mute_signals(signals.pre_save, signals.post_save): + signals.post_save.connect(self.handlers.mute_block_receiver) + WithSignalsFactory() + + self.assertTrue(self.handlers.mute_block_receiver.call_count, 1) + self.assertEqual(self.handlers.pre_init.call_count, 1) + self.assertFalse(self.handlers.pre_save.called) + self.assertFalse(self.handlers.post_save.called) + + self.assertSignalsReactivated() + self.assertTrue(self.handlers.mute_block_receiver.call_count, 1) + + def test_class_decorator(self): + @factory.django.mute_signals(signals.pre_save, signals.post_save) + class WithSignalsDecoratedFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.WithSignals + + WithSignalsDecoratedFactory() + + self.assertEqual(self.handlers.pre_init.call_count, 1) + self.assertFalse(self.handlers.pre_save.called) + self.assertFalse(self.handlers.post_save.called) + + self.assertSignalsReactivated() + + def test_class_decorator_with_subfactory(self): + @factory.django.mute_signals(signals.pre_save, signals.post_save) + class WithSignalsDecoratedFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.WithSignals + skip_postgeneration_save = True + + @factory.post_generation + def post(obj, create, extracted, **kwargs): + if not extracted: + WithSignalsDecoratedFactory.create(post=42) + + # This will disable the signals (twice), create two objects, + # and reactivate the signals. + WithSignalsDecoratedFactory() + + self.assertEqual(self.handlers.pre_init.call_count, 2) + self.assertFalse(self.handlers.pre_save.called) + self.assertFalse(self.handlers.post_save.called) + + self.assertSignalsReactivated() + + def test_class_decorator_related_model_with_post_hook(self): + """ + Related factory with post_generation hook should not call disabled signals. + + Refs https://github.com/FactoryBoy/factory_boy/issues/424 + """ + + @factory.django.mute_signals(signals.post_save) + class PointedFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.PointedModel + skip_postgeneration_save = True + + @factory.post_generation + def post_action(obj, create, extracted, **kwargs): + pass + + class PointerFactory(factory.django.DjangoModelFactory): + pointed = factory.SubFactory(PointedFactory) + + class Meta: + model = models.PointerModel + + PointerFactory.create() + + self.handlers.post_save.assert_called_once_with( + signal=mock.ANY, + sender=models.PointerModel, + instance=mock.ANY, + created=True, + update_fields=None, + raw=False, + using="default", + ) + + def test_class_decorator_build(self): + @factory.django.mute_signals(signals.pre_save, signals.post_save) + class WithSignalsDecoratedFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.WithSignals + + WithSignalsDecoratedFactory.build() + + self.assertEqual(self.handlers.pre_init.call_count, 1) + self.assertFalse(self.handlers.pre_save.called) + self.assertFalse(self.handlers.post_save.called) + + self.assertSignalsReactivated() + + def test_function_decorator(self): + @factory.django.mute_signals(signals.pre_save, signals.post_save) + def foo(): + WithSignalsFactory() + + foo() + + self.assertEqual(self.handlers.pre_init.call_count, 1) + self.assertFalse(self.handlers.pre_save.called) + self.assertFalse(self.handlers.post_save.called) + + self.assertSignalsReactivated() + + def test_classmethod_decorator(self): + class Foo: + @classmethod + @factory.django.mute_signals(signals.pre_save, signals.post_save) + def generate(cls): + WithSignalsFactory() + + Foo.generate() + + self.assertEqual(self.handlers.pre_init.call_count, 1) + self.assertFalse(self.handlers.pre_save.called) + self.assertFalse(self.handlers.post_save.called) + + self.assertSignalsReactivated() + + +class PreventChainedSignalsTestCase(django_test.TestCase): + + def setUp(self): + self.post_save_mock = mock.Mock(side_effect=Exception('BOOM!')) + signals.post_save.connect(self.post_save_mock, models.PointedModel) + + def tearDown(self): + signals.post_save.disconnect(self.post_save_mock, models.PointedModel) + + @factory.django.mute_signals(signals.post_save) + class WithSignalsDecoratedFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.PointedModel + + def test_class_decorator_with_muted_subfactory(self): + class UndecoratedFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.PointerModel + pointed = factory.SubFactory(self.WithSignalsDecoratedFactory) + + UndecoratedFactory() + self.post_save_mock.assert_not_called() + + def test_class_decorator_with_muted_related_factory(self): + class UndecoratedFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.PointerModel + skip_postgeneration_save = True + pointed = factory.RelatedFactory(self.WithSignalsDecoratedFactory) + + UndecoratedFactory() + self.post_save_mock.assert_not_called() + + +class DjangoCustomManagerTestCase(django_test.TestCase): + + def test_extra_args(self): + # Our CustomManager will remove the 'arg=' argument. + WithCustomManagerFactory(arg='foo') + + def test_with_manager_on_abstract(self): + class ObjFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.FromAbstractWithCustomManager + + # Our CustomManager will remove the 'arg=' argument, + # invalid for the actual model. + ObjFactory.create(arg='invalid') + + +class DjangoModelFactoryDuplicateSaveDeprecationTest(django_test.TestCase): + class StandardFactoryWithPost(StandardFactory): + @factory.post_generation + def post_action(obj, create, extracted, **kwargs): + return 3 + + def test_create_warning(self): + with self.assertWarns(DeprecationWarning) as cm: + self.StandardFactoryWithPost.create() + + [msg] = cm.warning.args + self.assertEqual( + msg, + "StandardFactoryWithPost._after_postgeneration will stop saving the " + "instance after postgeneration hooks in the next major release.\n" + "If the save call is extraneous, set skip_postgeneration_save=True in the " + "StandardFactoryWithPost.Meta.\n" + "To keep saving the instance, move the save call to your postgeneration " + "hooks or override _after_postgeneration.", + ) + + def test_build_no_warning(self): + self.StandardFactoryWithPost.build() + + +class IntegrityErrorForMissingOriginalParamsTest(django_test.TestCase): + + def test_raises_integrity_error(self): + """ + In factory.django.DjangoModelFactory._get_or_create + _original_params can give some trouble when None + + This test case verifies if the IntegrityError is correctly re-raised + """ + + class MultifieldModelFactory2(MultifieldModelFactory): + class Meta: + model = models.MultifieldModel + django_get_or_create = ['text'] + + class HasMultifieldModelFactory(factory.django.DjangoModelFactory): + multifield = factory.SubFactory(MultifieldModelFactory2) + + class Meta: + model = models.HasMultifieldModel -if __name__ == '__main__': # pragma: no cover - unittest.main() + HasMultifieldModelFactory(multifield__slug="test") + with self.assertRaises(IntegrityError): + HasMultifieldModelFactory(multifield__slug="test") diff --git a/tests/test_docs_internals.py b/tests/test_docs_internals.py new file mode 100644 index 00000000..cfc76377 --- /dev/null +++ b/tests/test_docs_internals.py @@ -0,0 +1,131 @@ +# Copyright (c) 2011-2015 Raphaël Barrois +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +"""Tests for the docs/internals module.""" + +import datetime +import unittest + +import factory +import factory.fuzzy + + +class User: + def __init__( + self, + username, + full_name, + is_active=True, + is_superuser=False, + is_staff=False, + creation_date=None, + deactivation_date=None, + ): + self.username = username + self.full_name = full_name + self.is_active = is_active + self.is_superuser = is_superuser + self.is_staff = is_staff + self.creation_date = creation_date + self.deactivation_date = deactivation_date + self.logs = [] + + def log(self, action, timestamp): + UserLog(user=self, action=action, timestamp=timestamp) + + +class UserLog: + + ACTIONS = ['create', 'update', 'disable'] + + def __init__(self, user, action, timestamp): + self.user = user + self.action = action + self.timestamp = timestamp + + user.logs.append(self) + + +class UserLogFactory(factory.Factory): + class Meta: + model = UserLog + + user = factory.SubFactory('test_docs_internals.UserFactory') + timestamp = factory.fuzzy.FuzzyDateTime( + datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc), + ) + action = factory.Iterator(UserLog.ACTIONS) + + +class UserFactory(factory.Factory): + class Meta: + model = User + + class Params: + # Allow us to quickly enable staff/superuser flags + superuser = factory.Trait( + is_superuser=True, + is_staff=True, + ) + # Meta parameter handling all 'enabled'-related fields + enabled = True + + # Classic fields + username = factory.Faker('user_name') + full_name = factory.Faker('name') + creation_date = factory.fuzzy.FuzzyDateTime( + datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc), + datetime.datetime(2015, 12, 31, 20, tzinfo=datetime.timezone.utc) + ) + + # Conditional flags + is_active = factory.SelfAttribute('enabled') + deactivation_date = factory.Maybe( + 'enabled', + None, + factory.fuzzy.FuzzyDateTime( + datetime.datetime.now().replace(tzinfo=datetime.timezone.utc) - datetime.timedelta(days=10), + datetime.datetime.now().replace(tzinfo=datetime.timezone.utc) - datetime.timedelta(days=1), + ), + ) + + # Related logs + creation_log = factory.RelatedFactory( + UserLogFactory, + factory_related_name='user', + action='create', + timestamp=factory.SelfAttribute('user.creation_date'), + ) + + +class DocsInternalsTests(unittest.TestCase): + def test_simple_usage(self): + user = UserFactory() + + # Default user should be active, not super + self.assertTrue(user.is_active) + self.assertFalse(user.is_superuser) + self.assertFalse(user.is_staff) + + # We should have one log + self.assertEqual(1, len(user.logs)) + # And it should be a 'create' action linked to the user's creation_date + self.assertEqual('create', user.logs[0].action) + self.assertEqual(user, user.logs[0].user) + self.assertEqual(user.creation_date, user.logs[0].timestamp) diff --git a/tests/test_faker.py b/tests/test_faker.py new file mode 100644 index 00000000..d1a16da0 --- /dev/null +++ b/tests/test_faker.py @@ -0,0 +1,170 @@ +# Copyright: See the LICENSE file. + +import collections +import datetime +import random +import unittest + +import faker.providers + +import factory + + +class MockFaker: + def __init__(self, expected): + self.expected = expected + self.random = random.Random() + + def format(self, provider, **kwargs): + return self.expected[provider] + + +class AdvancedMockFaker: + def __init__(self, handlers): + self.handlers = handlers + self.random = random.Random() + + def format(self, provider, **kwargs): + handler = self.handlers[provider] + return handler(**kwargs) + + +class FakerTests(unittest.TestCase): + def setUp(self): + self._real_fakers = factory.Faker._FAKER_REGISTRY + factory.Faker._FAKER_REGISTRY = {} + + def tearDown(self): + factory.Faker._FAKER_REGISTRY = self._real_fakers + + def _setup_mock_faker(self, locale=None, **definitions): + if locale is None: + locale = factory.Faker._DEFAULT_LOCALE + factory.Faker._FAKER_REGISTRY[locale] = MockFaker(definitions) + + def _setup_advanced_mock_faker(self, locale=None, **handlers): + if locale is None: + locale = factory.Faker._DEFAULT_LOCALE + factory.Faker._FAKER_REGISTRY[locale] = AdvancedMockFaker(handlers) + + def test_simple_biased(self): + self._setup_mock_faker(name="John Doe") + faker_field = factory.Faker('name') + self.assertEqual("John Doe", faker_field.evaluate(None, None, {'locale': None})) + + def test_full_factory(self): + class Profile: + def __init__(self, first_name, last_name, email): + self.first_name = first_name + self.last_name = last_name + self.email = email + + class ProfileFactory(factory.Factory): + class Meta: + model = Profile + first_name = factory.Faker('first_name') + last_name = factory.Faker('last_name', locale='fr_FR') + email = factory.Faker('email') + + self._setup_mock_faker(first_name="John", last_name="Doe", email="john.doe@example.org") + self._setup_mock_faker(first_name="Jean", last_name="Valjean", email="jvaljean@exemple.fr", locale='fr_FR') + + profile = ProfileFactory() + self.assertEqual("John", profile.first_name) + self.assertEqual("Valjean", profile.last_name) + self.assertEqual('john.doe@example.org', profile.email) + + def test_override_locale(self): + class Profile: + def __init__(self, first_name, last_name): + self.first_name = first_name + self.last_name = last_name + + class ProfileFactory(factory.Factory): + class Meta: + model = Profile + + first_name = factory.Faker('first_name') + last_name = factory.Faker('last_name', locale='fr_FR') + + self._setup_mock_faker(first_name="John", last_name="Doe") + self._setup_mock_faker(first_name="Jean", last_name="Valjean", locale='fr_FR') + self._setup_mock_faker(first_name="Johannes", last_name="Brahms", locale='de_DE') + + profile = ProfileFactory() + self.assertEqual("John", profile.first_name) + self.assertEqual("Valjean", profile.last_name) + + with factory.Faker.override_default_locale('de_DE'): + profile = ProfileFactory() + self.assertEqual("Johannes", profile.first_name) + self.assertEqual("Valjean", profile.last_name) + + profile = ProfileFactory() + self.assertEqual("John", profile.first_name) + self.assertEqual("Valjean", profile.last_name) + + def test_add_provider(self): + class Face: + def __init__(self, smiley, french_smiley): + self.smiley = smiley + self.french_smiley = french_smiley + + class FaceFactory(factory.Factory): + class Meta: + model = Face + + smiley = factory.Faker('smiley') + french_smiley = factory.Faker('smiley', locale='fr_FR') + + class SmileyProvider(faker.providers.BaseProvider): + def smiley(self): + return ':)' + + class FrenchSmileyProvider(faker.providers.BaseProvider): + def smiley(self): + return '(:' + + factory.Faker.add_provider(SmileyProvider) + factory.Faker.add_provider(FrenchSmileyProvider, 'fr_FR') + + face = FaceFactory() + self.assertEqual(":)", face.smiley) + self.assertEqual("(:", face.french_smiley) + + def test_faker_customization(self): + """Factory declarations in Faker parameters should be accepted.""" + Trip = collections.namedtuple('Trip', ['departure', 'transfer', 'arrival']) + + may_4th = datetime.date(1977, 5, 4) + may_25th = datetime.date(1977, 5, 25) + october_19th = datetime.date(1977, 10, 19) + + class TripFactory(factory.Factory): + class Meta: + model = Trip + + departure = may_4th + arrival = may_25th + transfer = factory.Faker( + 'date_between_dates', + start_date=factory.SelfAttribute('..departure'), + end_date=factory.SelfAttribute('..arrival'), + ) + + def fake_select_date(start_date, end_date): + """Fake date_between_dates.""" + # Ensure that dates have been transferred from the factory + # to Faker parameters. + self.assertEqual(start_date, may_4th) + self.assertEqual(end_date, may_25th) + return october_19th + + self._setup_advanced_mock_faker( + date_between_dates=fake_select_date, + ) + + trip = TripFactory() + self.assertEqual(may_4th, trip.departure) + self.assertEqual(october_19th, trip.transfer) + self.assertEqual(may_25th, trip.arrival) diff --git a/tests/test_fuzzy.py b/tests/test_fuzzy.py index d6f33bba..67cf7030 100644 --- a/tests/test_fuzzy.py +++ b/tests/test_fuzzy.py @@ -1,33 +1,14 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. import datetime import decimal +import unittest +import warnings +from unittest import mock -from factory import compat -from factory import fuzzy +from factory import fuzzy, random -from .compat import mock, unittest from . import utils @@ -35,10 +16,10 @@ class FuzzyAttributeTestCase(unittest.TestCase): def test_simple_call(self): d = fuzzy.FuzzyAttribute(lambda: 10) - res = d.evaluate(2, None, False) + res = utils.evaluate_declaration(d) self.assertEqual(10, res) - res = d.evaluate(2, None, False) + res = utils.evaluate_declaration(d) self.assertEqual(10, res) @@ -46,7 +27,7 @@ class FuzzyChoiceTestCase(unittest.TestCase): def test_unbiased(self): options = [1, 2, 3] d = fuzzy.FuzzyChoice(options) - res = d.evaluate(2, None, False) + res = utils.evaluate_declaration(d) self.assertIn(res, options) def test_mock(self): @@ -55,58 +36,91 @@ def test_mock(self): d = fuzzy.FuzzyChoice(options) - with mock.patch('random.choice', fake_choice): - res = d.evaluate(2, None, False) + with mock.patch('factory.random.randgen.choice', fake_choice): + res = utils.evaluate_declaration(d) self.assertEqual(6, res) def test_generator(self): def options(): - for i in range(3): - yield i + yield from range(3) d = fuzzy.FuzzyChoice(options()) - res = d.evaluate(2, None, False) + res = utils.evaluate_declaration(d) self.assertIn(res, [0, 1, 2]) # And repeat - res = d.evaluate(2, None, False) + res = utils.evaluate_declaration(d) self.assertIn(res, [0, 1, 2]) + def test_lazy_generator(self): + class Gen: + def __init__(self, options): + self.options = options + self.unrolled = False + + def __iter__(self): + self.unrolled = True + return iter(self.options) + + opts = Gen([1, 2, 3]) + d = fuzzy.FuzzyChoice(opts) + self.assertFalse(opts.unrolled) + + res = utils.evaluate_declaration(d) + self.assertIn(res, [1, 2, 3]) + self.assertTrue(opts.unrolled) + + def test_getter(self): + options = [('a', 1), ('b', 2), ('c', 3)] + d = fuzzy.FuzzyChoice(options, getter=lambda x: x[1]) + res = utils.evaluate_declaration(d) + self.assertIn(res, [1, 2, 3]) + class FuzzyIntegerTestCase(unittest.TestCase): def test_definition(self): """Tests all ways of defining a FuzzyInteger.""" fuzz = fuzzy.FuzzyInteger(2, 3) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertIn(res, [2, 3]) fuzz = fuzzy.FuzzyInteger(4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertIn(res, [0, 1, 2, 3, 4]) def test_biased(self): - fake_randint = lambda low, high: low + high + fake_randrange = lambda low, high, step: (low + high) * step fuzz = fuzzy.FuzzyInteger(2, 8) - with mock.patch('random.randint', fake_randint): - res = fuzz.evaluate(2, None, False) + with mock.patch('factory.random.randgen.randrange', fake_randrange): + res = utils.evaluate_declaration(fuzz) - self.assertEqual(10, res) + self.assertEqual((2 + 8 + 1) * 1, res) def test_biased_high_only(self): - fake_randint = lambda low, high: low + high + fake_randrange = lambda low, high, step: (low + high) * step fuzz = fuzzy.FuzzyInteger(8) - with mock.patch('random.randint', fake_randint): - res = fuzz.evaluate(2, None, False) + with mock.patch('factory.random.randgen.randrange', fake_randrange): + res = utils.evaluate_declaration(fuzz) + + self.assertEqual((0 + 8 + 1) * 1, res) - self.assertEqual(8, res) + def test_biased_with_step(self): + fake_randrange = lambda low, high, step: (low + high) * step + + fuzz = fuzzy.FuzzyInteger(5, 8, 3) + + with mock.patch('factory.random.randgen.randrange', fake_randrange): + res = utils.evaluate_declaration(fuzz) + + self.assertEqual((5 + 8 + 1) * 3, res) class FuzzyDecimalTestCase(unittest.TestCase): @@ -114,21 +128,27 @@ def test_definition(self): """Tests all ways of defining a FuzzyDecimal.""" fuzz = fuzzy.FuzzyDecimal(2.0, 3.0) for _i in range(20): - res = fuzz.evaluate(2, None, False) - self.assertTrue(decimal.Decimal('2.0') <= res <= decimal.Decimal('3.0'), - "value %d is not between 2.0 and 3.0" % res) + res = utils.evaluate_declaration(fuzz) + self.assertTrue( + decimal.Decimal('2.0') <= res <= decimal.Decimal('3.0'), + "value %d is not between 2.0 and 3.0" % res, + ) fuzz = fuzzy.FuzzyDecimal(4.0) for _i in range(20): - res = fuzz.evaluate(2, None, False) - self.assertTrue(decimal.Decimal('0.0') <= res <= decimal.Decimal('4.0'), - "value %d is not between 0.0 and 4.0" % res) + res = utils.evaluate_declaration(fuzz) + self.assertTrue( + decimal.Decimal('0.0') <= res <= decimal.Decimal('4.0'), + "value %d is not between 0.0 and 4.0" % res, + ) fuzz = fuzzy.FuzzyDecimal(1.0, 4.0, precision=5) for _i in range(20): - res = fuzz.evaluate(2, None, False) - self.assertTrue(decimal.Decimal('0.54') <= res <= decimal.Decimal('4.0'), - "value %d is not between 0.54 and 4.0" % res) + res = utils.evaluate_declaration(fuzz) + self.assertTrue( + decimal.Decimal('1.0') <= res <= decimal.Decimal('4.0'), + "value %d is not between 1.0 and 4.0" % res, + ) self.assertTrue(res.as_tuple().exponent, -5) def test_biased(self): @@ -136,8 +156,8 @@ def test_biased(self): fuzz = fuzzy.FuzzyDecimal(2.0, 8.0) - with mock.patch('random.uniform', fake_uniform): - res = fuzz.evaluate(2, None, False) + with mock.patch('factory.random.randgen.uniform', fake_uniform): + res = utils.evaluate_declaration(fuzz) self.assertEqual(decimal.Decimal('10.0'), res) @@ -146,8 +166,8 @@ def test_biased_high_only(self): fuzz = fuzzy.FuzzyDecimal(8.0) - with mock.patch('random.uniform', fake_uniform): - res = fuzz.evaluate(2, None, False) + with mock.patch('factory.random.randgen.uniform', fake_uniform): + res = utils.evaluate_declaration(fuzz) self.assertEqual(decimal.Decimal('8.0'), res) @@ -156,11 +176,83 @@ def test_precision(self): fuzz = fuzzy.FuzzyDecimal(8.0, precision=3) - with mock.patch('random.uniform', fake_uniform): - res = fuzz.evaluate(2, None, False) + with mock.patch('factory.random.randgen.uniform', fake_uniform): + res = utils.evaluate_declaration(fuzz) self.assertEqual(decimal.Decimal('8.001').quantize(decimal.Decimal(10) ** -3), res) + def test_no_approximation(self): + """We should not go through floats in our fuzzy calls unless actually needed.""" + fuzz = fuzzy.FuzzyDecimal(0, 10) + + decimal_context = decimal.getcontext() + old_traps = decimal_context.traps[decimal.FloatOperation] + try: + decimal_context.traps[decimal.FloatOperation] = True + utils.evaluate_declaration(fuzz) + finally: + decimal_context.traps[decimal.FloatOperation] = old_traps + + +class FuzzyFloatTestCase(unittest.TestCase): + def test_definition(self): + """Tests all ways of defining a FuzzyFloat.""" + fuzz = fuzzy.FuzzyFloat(2.0, 3.0) + for _i in range(20): + res = utils.evaluate_declaration(fuzz) + self.assertTrue(2.0 <= res <= 3.0, "value %d is not between 2.0 and 3.0" % res) + + fuzz = fuzzy.FuzzyFloat(4.0) + for _i in range(20): + res = utils.evaluate_declaration(fuzz) + self.assertTrue(0.0 <= res <= 4.0, "value %d is not between 0.0 and 4.0" % res) + + fuzz = fuzzy.FuzzyDecimal(1.0, 4.0, precision=5) + for _i in range(20): + res = utils.evaluate_declaration(fuzz) + self.assertTrue(1.0 <= res <= 4.0, "value %d is not between 1.0 and 4.0" % res) + self.assertTrue(res.as_tuple().exponent, -5) + + def test_biased(self): + fake_uniform = lambda low, high: low + high + + fuzz = fuzzy.FuzzyFloat(2.0, 8.0) + + with mock.patch('factory.random.randgen.uniform', fake_uniform): + res = utils.evaluate_declaration(fuzz) + + self.assertEqual(10.0, res) + + def test_biased_high_only(self): + fake_uniform = lambda low, high: low + high + + fuzz = fuzzy.FuzzyFloat(8.0) + + with mock.patch('factory.random.randgen.uniform', fake_uniform): + res = utils.evaluate_declaration(fuzz) + + self.assertEqual(8.0, res) + + def test_default_precision(self): + fake_uniform = lambda low, high: low + high + 0.000000000000011 + + fuzz = fuzzy.FuzzyFloat(8.0) + + with mock.patch('factory.random.randgen.uniform', fake_uniform): + res = utils.evaluate_declaration(fuzz) + + self.assertEqual(8.00000000000001, res) + + def test_precision(self): + fake_uniform = lambda low, high: low + high + 0.001 + + fuzz = fuzzy.FuzzyFloat(8.0, precision=4) + + with mock.patch('factory.random.randgen.uniform', fake_uniform): + res = utils.evaluate_declaration(fuzz) + + self.assertEqual(8.001, res) + class FuzzyDateTestCase(unittest.TestCase): @classmethod @@ -175,7 +267,7 @@ def test_accurate_definition(self): fuzz = fuzzy.FuzzyDate(self.jan1, self.jan31) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertLessEqual(self.jan1, res) self.assertLessEqual(res, self.jan31) @@ -185,18 +277,18 @@ def test_partial_definition(self): fuzz = fuzzy.FuzzyDate(self.jan1) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertLessEqual(self.jan1, res) self.assertLessEqual(res, self.jan3) def test_invalid_definition(self): - self.assertRaises(ValueError, fuzzy.FuzzyDate, - self.jan31, self.jan1) + with self.assertRaises(ValueError): + fuzzy.FuzzyDate(self.jan31, self.jan1) def test_invalid_partial_definition(self): with utils.mocked_date_today(self.jan1, fuzzy): - self.assertRaises(ValueError, fuzzy.FuzzyDate, - self.jan31) + with self.assertRaises(ValueError): + fuzzy.FuzzyDate(self.jan31) def test_biased(self): """Tests a FuzzyDate with a biased random.randint.""" @@ -204,8 +296,8 @@ def test_biased(self): fake_randint = lambda low, high: (low + high) // 2 fuzz = fuzzy.FuzzyDate(self.jan1, self.jan31) - with mock.patch('random.randint', fake_randint): - res = fuzz.evaluate(2, None, False) + with mock.patch('factory.random.randgen.randint', fake_randint): + res = utils.evaluate_declaration(fuzz) self.assertEqual(datetime.date(2013, 1, 16), res) @@ -215,8 +307,8 @@ def test_biased_partial(self): fuzz = fuzzy.FuzzyDate(self.jan1) fake_randint = lambda low, high: (low + high) // 2 - with mock.patch('random.randint', fake_randint): - res = fuzz.evaluate(2, None, False) + with mock.patch('factory.random.randgen.randint', fake_randint): + res = utils.evaluate_declaration(fuzz) self.assertEqual(datetime.date(2013, 1, 2), res) @@ -234,7 +326,7 @@ def test_accurate_definition(self): fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertLessEqual(self.jan1, res) self.assertLessEqual(res, self.jan31) @@ -244,77 +336,77 @@ def test_partial_definition(self): fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertLessEqual(self.jan1, res) self.assertLessEqual(res, self.jan3) def test_aware_start(self): """Tests that a timezone-aware start datetime is rejected.""" - self.assertRaises(ValueError, fuzzy.FuzzyNaiveDateTime, - self.jan1.replace(tzinfo=compat.UTC), self.jan31) + with self.assertRaises(ValueError): + fuzzy.FuzzyNaiveDateTime(self.jan1.replace(tzinfo=datetime.timezone.utc), self.jan31) def test_aware_end(self): """Tests that a timezone-aware end datetime is rejected.""" - self.assertRaises(ValueError, fuzzy.FuzzyNaiveDateTime, - self.jan1, self.jan31.replace(tzinfo=compat.UTC)) + with self.assertRaises(ValueError): + fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31.replace(tzinfo=datetime.timezone.utc)) def test_force_year(self): fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_year=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.year) def test_force_month(self): fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_month=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.month) def test_force_day(self): fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_day=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.day) def test_force_hour(self): fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_hour=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.hour) def test_force_minute(self): fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_minute=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.minute) def test_force_second(self): fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_second=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.second) def test_force_microsecond(self): fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31, force_microsecond=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.microsecond) def test_invalid_definition(self): - self.assertRaises(ValueError, fuzzy.FuzzyNaiveDateTime, - self.jan31, self.jan1) + with self.assertRaises(ValueError): + fuzzy.FuzzyNaiveDateTime(self.jan31, self.jan1) def test_invalid_partial_definition(self): with utils.mocked_datetime_now(self.jan1, fuzzy): - self.assertRaises(ValueError, fuzzy.FuzzyNaiveDateTime, - self.jan31) + with self.assertRaises(ValueError): + fuzzy.FuzzyNaiveDateTime(self.jan31) def test_biased(self): """Tests a FuzzyDate with a biased random.randint.""" @@ -322,8 +414,8 @@ def test_biased(self): fake_randint = lambda low, high: (low + high) // 2 fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1, self.jan31) - with mock.patch('random.randint', fake_randint): - res = fuzz.evaluate(2, None, False) + with mock.patch('factory.random.randgen.randint', fake_randint): + res = utils.evaluate_declaration(fuzz) self.assertEqual(datetime.datetime(2013, 1, 16), res) @@ -333,8 +425,8 @@ def test_biased_partial(self): fuzz = fuzzy.FuzzyNaiveDateTime(self.jan1) fake_randint = lambda low, high: (low + high) // 2 - with mock.patch('random.randint', fake_randint): - res = fuzz.evaluate(2, None, False) + with mock.patch('factory.random.randgen.randint', fake_randint): + res = utils.evaluate_declaration(fuzz) self.assertEqual(datetime.datetime(2013, 1, 2), res) @@ -343,16 +435,16 @@ class FuzzyDateTimeTestCase(unittest.TestCase): @classmethod def setUpClass(cls): # Setup useful constants - cls.jan1 = datetime.datetime(2013, 1, 1, tzinfo=compat.UTC) - cls.jan3 = datetime.datetime(2013, 1, 3, tzinfo=compat.UTC) - cls.jan31 = datetime.datetime(2013, 1, 31, tzinfo=compat.UTC) + cls.jan1 = datetime.datetime(2013, 1, 1, tzinfo=datetime.timezone.utc) + cls.jan3 = datetime.datetime(2013, 1, 3, tzinfo=datetime.timezone.utc) + cls.jan31 = datetime.datetime(2013, 1, 31, tzinfo=datetime.timezone.utc) def test_accurate_definition(self): """Tests explicit definition of a FuzzyDateTime.""" fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertLessEqual(self.jan1, res) self.assertLessEqual(res, self.jan31) @@ -362,76 +454,76 @@ def test_partial_definition(self): fuzz = fuzzy.FuzzyDateTime(self.jan1) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertLessEqual(self.jan1, res) self.assertLessEqual(res, self.jan3) def test_invalid_definition(self): - self.assertRaises(ValueError, fuzzy.FuzzyDateTime, - self.jan31, self.jan1) + with self.assertRaises(ValueError): + fuzzy.FuzzyDateTime(self.jan31, self.jan1) def test_invalid_partial_definition(self): with utils.mocked_datetime_now(self.jan1, fuzzy): - self.assertRaises(ValueError, fuzzy.FuzzyDateTime, - self.jan31) + with self.assertRaises(ValueError): + fuzzy.FuzzyDateTime(self.jan31) def test_naive_start(self): """Tests that a timezone-naive start datetime is rejected.""" - self.assertRaises(ValueError, fuzzy.FuzzyDateTime, - self.jan1.replace(tzinfo=None), self.jan31) + with self.assertRaises(ValueError): + fuzzy.FuzzyDateTime(self.jan1.replace(tzinfo=None), self.jan31) def test_naive_end(self): """Tests that a timezone-naive end datetime is rejected.""" - self.assertRaises(ValueError, fuzzy.FuzzyDateTime, - self.jan1, self.jan31.replace(tzinfo=None)) + with self.assertRaises(ValueError): + fuzzy.FuzzyDateTime(self.jan1, self.jan31.replace(tzinfo=None)) def test_force_year(self): fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_year=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.year) def test_force_month(self): fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_month=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.month) def test_force_day(self): fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_day=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.day) def test_force_hour(self): fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_hour=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.hour) def test_force_minute(self): fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_minute=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.minute) def test_force_second(self): fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_second=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.second) def test_force_microsecond(self): fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31, force_microsecond=4) for _i in range(20): - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(4, res.microsecond) def test_biased(self): @@ -440,10 +532,10 @@ def test_biased(self): fake_randint = lambda low, high: (low + high) // 2 fuzz = fuzzy.FuzzyDateTime(self.jan1, self.jan31) - with mock.patch('random.randint', fake_randint): - res = fuzz.evaluate(2, None, False) + with mock.patch('factory.random.randgen.randint', fake_randint): + res = utils.evaluate_declaration(fuzz) - self.assertEqual(datetime.datetime(2013, 1, 16, tzinfo=compat.UTC), res) + self.assertEqual(datetime.datetime(2013, 1, 16, tzinfo=datetime.timezone.utc), res) def test_biased_partial(self): """Tests a FuzzyDate with a biased random and implicit upper bound.""" @@ -451,10 +543,10 @@ def test_biased_partial(self): fuzz = fuzzy.FuzzyDateTime(self.jan1) fake_randint = lambda low, high: (low + high) // 2 - with mock.patch('random.randint', fake_randint): - res = fuzz.evaluate(2, None, False) + with mock.patch('factory.random.randgen.randint', fake_randint): + res = utils.evaluate_declaration(fuzz) - self.assertEqual(datetime.datetime(2013, 1, 2, tzinfo=compat.UTC), res) + self.assertEqual(datetime.datetime(2013, 1, 2, tzinfo=datetime.timezone.utc), res) class FuzzyTextTestCase(unittest.TestCase): @@ -462,7 +554,7 @@ class FuzzyTextTestCase(unittest.TestCase): def test_unbiased(self): chars = ['a', 'b', 'c'] fuzz = fuzzy.FuzzyText(prefix='pre', suffix='post', chars=chars, length=12) - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual('pre', res[:3]) self.assertEqual('post', res[-4:]) @@ -476,8 +568,8 @@ def test_mock(self): chars = ['a', 'b', 'c'] fuzz = fuzzy.FuzzyText(prefix='pre', suffix='post', chars=chars, length=4) - with mock.patch('random.choice', fake_choice): - res = fuzz.evaluate(2, None, False) + with mock.patch('factory.random.randgen.choice', fake_choice): + res = utils.evaluate_declaration(fuzz) self.assertEqual('preaaaapost', res) @@ -488,9 +580,40 @@ def options(): yield 'c' fuzz = fuzzy.FuzzyText(chars=options(), length=12) - res = fuzz.evaluate(2, None, False) + res = utils.evaluate_declaration(fuzz) self.assertEqual(12, len(res)) for char in res: self.assertIn(char, ['a', 'b', 'c']) + + +class FuzzyRandomTestCase(unittest.TestCase): + def test_seeding(self): + fuzz = fuzzy.FuzzyInteger(1, 1000) + + random.reseed_random(42) + value = utils.evaluate_declaration(fuzz) + + random.reseed_random(42) + value2 = utils.evaluate_declaration(fuzz) + self.assertEqual(value, value2) + + def test_seeding_warning(self): + with warnings.catch_warnings(record=True) as w: + # Do not turn expected warning into an error. + warnings.filterwarnings("default", category=UserWarning, module=r"tests\.test_fuzzy") + fuzz = fuzzy.FuzzyDate(datetime.date(2013, 1, 1)) + utils.evaluate_declaration(fuzz) + self.assertEqual(1, len(w)) + self.assertIn('factory_boy/issues/331', str(w[-1].message)) + + def test_reset_state(self): + fuzz = fuzzy.FuzzyInteger(1, 1000) + + state = random.get_random_state() + value = utils.evaluate_declaration(fuzz) + + random.set_random_state(state) + value2 = utils.evaluate_declaration(fuzz) + self.assertEqual(value, value2) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f5a66e5e..2c187448 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,31 +1,11 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. +import io import logging +import unittest from factory import helpers -from .compat import io, unittest - class DebugTest(unittest.TestCase): """Tests for the 'factory.debug()' helper.""" @@ -34,18 +14,18 @@ def test_default_logger(self): stream1 = io.StringIO() stream2 = io.StringIO() - l = logging.getLogger('factory.test') + logger = logging.getLogger('factory.test') h = logging.StreamHandler(stream1) h.setLevel(logging.INFO) - l.addHandler(h) + logger.addHandler(h) # Non-debug: no text gets out - l.debug("Test") + logger.debug("Test") self.assertEqual('', stream1.getvalue()) with helpers.debug(stream=stream2): # Debug: text goes to new stream only - l.debug("Test2") + logger.debug("Test2") self.assertEqual('', stream1.getvalue()) self.assertEqual("Test2\n", stream2.getvalue()) @@ -74,3 +54,15 @@ def test_alternate_logger(self): self.assertEqual("", stream1.getvalue()) self.assertEqual("Test2\n", stream2.getvalue()) + def test_restores_logging_on_error(self): + class MyException(Exception): + pass + + stream = io.StringIO() + try: + with helpers.debug(stream=stream): + raise MyException + except MyException: + logger = logging.getLogger('factory') + self.assertEqual(logger.level, logging.NOTSET) + self.assertEqual(logger.handlers, []) diff --git a/tests/test_mongoengine.py b/tests/test_mongoengine.py index f26eb858..ea1ae687 100644 --- a/tests/test_mongoengine.py +++ b/tests/test_mongoengine.py @@ -1,75 +1,79 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2013 Romain Command& -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -"""Tests for factory_boy/SQLAlchemy interactions.""" +# Copyright: See the LICENSE file. -import factory -import os -from .compat import unittest +"""Tests for factory_boy/MongoEngine interactions.""" +import os +import unittest try: import mongoengine except ImportError: - mongoengine = None + raise unittest.SkipTest("mongodb tests disabled.") + +import mongomock + +import factory +from factory.mongoengine import MongoEngineFactory + -if mongoengine: - from factory.mongoengine import MongoEngineFactory +class Address(mongoengine.EmbeddedDocument): + street = mongoengine.StringField() - class Person(mongoengine.Document): - name = mongoengine.StringField() - class PersonFactory(MongoEngineFactory): - FACTORY_FOR = Person +class Person(mongoengine.Document): + name = mongoengine.StringField() + address = mongoengine.EmbeddedDocumentField(Address) - name = factory.Sequence(lambda n: 'name%d' % n) +class AddressFactory(MongoEngineFactory): + class Meta: + model = Address + + street = factory.Sequence(lambda n: 'street%d' % n) + + +class PersonFactory(MongoEngineFactory): + class Meta: + model = Person + + name = factory.Sequence(lambda n: 'name%d' % n) + address = factory.SubFactory(AddressFactory) -@unittest.skipIf(mongoengine is None, "mongoengine not installed.") class MongoEngineTestCase(unittest.TestCase): db_name = os.environ.get('MONGO_DATABASE', 'factory_boy_test') db_host = os.environ.get('MONGO_HOST', 'localhost') db_port = int(os.environ.get('MONGO_PORT', '27017')) + server_timeout_ms = int(os.environ.get('MONGO_TIMEOUT', '300')) @classmethod def setUpClass(cls): - cls.db = mongoengine.connect(cls.db_name, host=cls.db_host, port=cls.db_port) + from pymongo import read_preferences as mongo_rp + cls.db = mongoengine.connect( + db=cls.db_name, + host=cls.db_host, + port=cls.db_port, + mongo_client_class=mongomock.MongoClient, + # PyMongo>=2.1 requires an explicit read_preference. + read_preference=mongo_rp.ReadPreference.PRIMARY, + # PyMongo>=2.1 has a 20s timeout, use 100ms instead + serverselectiontimeoutms=cls.server_timeout_ms, + uuidRepresentation='standard', + ) @classmethod def tearDownClass(cls): cls.db.drop_database(cls.db_name) - def setUp(self): - mongoengine.connect('factory_boy_test') - def test_build(self): std = PersonFactory.build() self.assertEqual('name0', std.name) + self.assertEqual('street0', std.address.street) self.assertIsNone(std.id) def test_creation(self): std1 = PersonFactory.create() self.assertEqual('name1', std1.name) + self.assertEqual('street1', std1.address.street) self.assertIsNotNone(std1.id) - - diff --git a/tests/test_regression.py b/tests/test_regression.py new file mode 100644 index 00000000..2cca0bda --- /dev/null +++ b/tests/test_regression.py @@ -0,0 +1,74 @@ +# Copyright: See the LICENSE file. + + +"""Regression tests related to issues found with the project""" + +import datetime +import typing as T +import unittest + +import factory + +# Example objects +# =============== + + +class Author(T.NamedTuple): + fullname: str + pseudonym: T.Optional[str] = None + + +class Book(T.NamedTuple): + title: str + author: Author + + +class PublishedBook(T.NamedTuple): + book: Book + published_on: datetime.date + countries: T.List[str] + + +class FakerRegressionTests(unittest.TestCase): + def test_locale_issue(self): + """Regression test for `KeyError: 'locale'` + + See #785 #786 #787 #788 #790 #796. + """ + class AuthorFactory(factory.Factory): + class Meta: + model = Author + + class Params: + unknown = factory.Trait( + fullname="", + ) + + fullname = factory.Faker("name") + + public_author = AuthorFactory(unknown=False) + self.assertIsNone(public_author.pseudonym) + + unknown_author = AuthorFactory(unknown=True) + self.assertEqual("", unknown_author.fullname) + + def test_evaluated_without_locale(self): + """Regression test for `KeyError: 'locale'` raised in `evaluate`. + + See #965 + + """ + class AuthorFactory(factory.Factory): + fullname = factory.Faker("name") + pseudonym = factory.Maybe( + decider=factory.Faker("pybool"), + yes_declaration="yes", + no_declaration="no", + ) + + class Meta: + model = Author + + author = AuthorFactory() + + self.assertIn(author.pseudonym, ["yes", "no"]) diff --git a/tests/test_transformer.py b/tests/test_transformer.py new file mode 100644 index 00000000..b21a8da9 --- /dev/null +++ b/tests/test_transformer.py @@ -0,0 +1,230 @@ +# Copyright: See the LICENSE file. + +from unittest import TestCase + +import factory + + +class TransformCounter: + calls_count = 0 + + @classmethod + def __call__(cls, x): + cls.calls_count += 1 + return x.upper() + + @classmethod + def reset(cls): + cls.calls_count = 0 + + +transform = TransformCounter() + + +class Upper: + def __init__(self, name, **extra): + self.name = name + self.extra = extra + + +class UpperFactory(factory.Factory): + name = factory.Transformer("value", transform=transform) + + class Meta: + model = Upper + + +class TransformerTest(TestCase): + def setUp(self): + transform.reset() + + def test_transform_count(self): + self.assertEqual("VALUE", UpperFactory().name) + self.assertEqual(transform.calls_count, 1) + + def test_transform_kwarg(self): + self.assertEqual("TEST", UpperFactory(name="test").name) + self.assertEqual(transform.calls_count, 1) + self.assertEqual("VALUE", UpperFactory().name) + self.assertEqual(transform.calls_count, 2) + + def test_transform_faker(self): + value = UpperFactory(name=factory.Faker("first_name_female", locale="fr")).name + self.assertIs(value.isupper(), True) + + def test_transform_linked(self): + value = UpperFactory( + name=factory.LazyAttribute(lambda o: o.username.replace(".", " ")), + username="john.doe", + ).name + self.assertEqual(value, "JOHN DOE") + + def test_force_value(self): + value = UpperFactory(name=factory.Transformer.Force("Mia")).name + self.assertEqual(value, "Mia") + + def test_force_value_declaration(self): + """Pretty unlikely use case, but easy enough to cover.""" + value = UpperFactory( + name=factory.Transformer.Force( + factory.LazyFunction(lambda: "infinity") + ) + ).name + self.assertEqual(value, "infinity") + + def test_force_value_declaration_context(self): + """Ensure "forced" values run at the right level.""" + value = UpperFactory( + name=factory.Transformer.Force( + factory.LazyAttribute(lambda o: o.username.replace(".", " ")), + ), + username="john.doe", + ).name + self.assertEqual(value, "john doe") + + +class TestObject: + def __init__(self, one=None, two=None, three=None): + self.one = one + self.two = two + self.three = three + + +class TransformDeclarationFactory(factory.Factory): + class Meta: + model = TestObject + one = factory.Transformer("", transform=str.upper) + two = factory.Transformer(factory.Sequence(int), transform=lambda n: n ** 2) + + +class TransformerSequenceTest(TestCase): + def test_on_sequence(self): + instance = TransformDeclarationFactory(__sequence=2) + self.assertEqual(instance.one, "") + self.assertEqual(instance.two, 4) + self.assertIsNone(instance.three) + + def test_on_user_supplied(self): + """A transformer can wrap a call-time declaration""" + instance = TransformDeclarationFactory( + one=factory.Sequence(str), + two=2, + __sequence=2, + ) + self.assertEqual(instance.one, "2") + self.assertEqual(instance.two, 4) + self.assertIsNone(instance.three) + + +class WithMaybeFactory(factory.Factory): + class Meta: + model = TestObject + + one = True + two = factory.Maybe( + 'one', + yes_declaration=factory.Transformer("yes", transform=str.upper), + no_declaration=factory.Transformer("no", transform=str.upper), + ) + three = factory.Maybe('one', no_declaration=factory.Transformer("three", transform=str.upper)) + + +class TransformerMaybeTest(TestCase): + def test_default_transform(self): + instance = WithMaybeFactory() + self.assertIs(instance.one, True) + self.assertEqual(instance.two, "YES") + self.assertIsNone(instance.three) + + def test_yes_transform(self): + instance = WithMaybeFactory(one=True) + self.assertIs(instance.one, True) + self.assertEqual(instance.two, "YES") + self.assertIsNone(instance.three) + + def test_no_transform(self): + instance = WithMaybeFactory(one=False) + self.assertIs(instance.one, False) + self.assertEqual(instance.two, "NO") + self.assertEqual(instance.three, "THREE") + + def test_override(self): + instance = WithMaybeFactory(one=True, two="NI") + self.assertIs(instance.one, True) + self.assertEqual(instance.two, "NI") + self.assertIsNone(instance.three) + + +class RelatedTest(TestCase): + def test_default_transform(self): + cities = [] + + class City: + def __init__(self, capital_of, name): + self.capital_of = capital_of + self.name = name + cities.append(self) + + class Country: + def __init__(self, name): + self.name = name + + class CityFactory(factory.Factory): + class Meta: + model = City + + name = "Rennes" + + class CountryFactory(factory.Factory): + class Meta: + model = Country + + name = "France" + capital_city = factory.RelatedFactory( + CityFactory, + factory_related_name="capital_of", + name=factory.Transformer("Paris", transform=str.upper), + ) + + instance = CountryFactory() + self.assertEqual(instance.name, "France") + [city] = cities + self.assertEqual(city.capital_of, instance) + self.assertEqual(city.name, "PARIS") + + +class WithTraitFactory(factory.Factory): + class Meta: + model = TestObject + + class Params: + upper_two = factory.Trait( + two=factory.Transformer("two", transform=str.upper) + ) + odds = factory.Trait( + one="one", + three="three", + ) + one = factory.Transformer("one", transform=str.upper) + + +class TransformerTraitTest(TestCase): + def test_traits_off(self): + instance = WithTraitFactory() + self.assertEqual(instance.one, "ONE") + self.assertIsNone(instance.two) + self.assertIsNone(instance.three) + + def test_trait_transform_applies(self): + """A trait-provided transformer should apply to existing values""" + instance = WithTraitFactory(upper_two=True) + self.assertEqual(instance.one, "ONE") + self.assertEqual(instance.two, "TWO") + self.assertIsNone(instance.three) + + def test_trait_transform_applies_supplied(self): + """A trait-provided transformer should be overridden by caller-provided values""" + instance = WithTraitFactory(upper_two=True, two="two") + self.assertEqual(instance.one, "ONE") + self.assertEqual(instance.two, "two") + self.assertIsNone(instance.three) diff --git a/tests/test_typing.py b/tests/test_typing.py new file mode 100644 index 00000000..c2f8b564 --- /dev/null +++ b/tests/test_typing.py @@ -0,0 +1,31 @@ +# Copyright: See the LICENSE file. + +import dataclasses +import unittest + +import factory + + +@dataclasses.dataclass +class User: + name: str + email: str + id: int + + +class TypingTests(unittest.TestCase): + + def test_simple_factory(self) -> None: + + class UserFactory(factory.Factory[User]): + name = "John Doe" + email = "john.doe@example.org" + id = 42 + + class Meta: + model = User + + result: User + result = UserFactory.build() + result = UserFactory.create() + self.assertEqual(result.name, "John Doe") diff --git a/tests/test_using.py b/tests/test_using.py index 3979cd0f..5b2200a6 100644 --- a/tests/test_using.py +++ b/tests/test_using.py @@ -1,38 +1,27 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. + """Tests using factory.""" -import functools +import collections +import datetime import os import sys -import warnings +import unittest import factory +from factory import errors + +from . import utils -from .compat import is_python2, unittest -from . import tools +try: + import django # noqa: F401 + SKIP_DJANGO = False +except ImportError: + SKIP_DJANGO = True -class TestObject(object): +class TestObject: def __init__(self, one=None, two=None, three=None, four=None, five=None): self.one = one self.two = two @@ -40,15 +29,41 @@ def __init__(self, one=None, two=None, three=None, four=None, five=None): self.four = four self.five = five + def as_dict(self): + return dict( + one=self.one, + two=self.two, + three=self.three, + four=self.four, + five=self.five, + ) + + +class Dummy: + def __init__(self, **kwargs): + self._fields = set(kwargs) + for k, v in kwargs.items(): + setattr(self, k, v) -class FakeModel(object): + @property + def as_dict(self): + return {field: getattr(self, field) for field in self._fields} + + def __repr__(self): + return '%s(%s)' % ( + self.__class__.__name__, + ', '.join('%s=%r' % pair for pair in sorted(self.as_dict.items())) + ) + + +class FakeModel: @classmethod def create(cls, **kwargs): instance = cls(**kwargs) instance.id = 1 return instance - class FakeModelManager(object): + class FakeModelManager: def get_or_create(self, **kwargs): defaults = kwargs.pop('defaults', {}) kwargs.update(defaults) @@ -69,6 +84,9 @@ def values_list(self, *args, **kwargs): def order_by(self, *args, **kwargs): return [1] + def using(self, db): + return self + objects = FakeModelManager() def __init__(self, **kwargs): @@ -78,11 +96,12 @@ def __init__(self, **kwargs): class FakeModelFactory(factory.Factory): - ABSTRACT_FACTORY = True + class Meta: + abstract = True @classmethod - def _create(cls, target_class, *args, **kwargs): - return target_class.create(**kwargs) + def _create(cls, model_class, *args, **kwargs): + return model_class.create(**kwargs) class TestModel(FakeModel): @@ -107,8 +126,12 @@ def test_complex(self): self.assertEqual(obj.four, None) def test_build_batch(self): - objs = factory.build_batch(TestObject, 4, two=2, - three=factory.LazyAttribute(lambda o: o.two + 1)) + objs = factory.build_batch( + TestObject, + 4, + two=2, + three=factory.LazyAttribute(lambda o: o.two + 1), + ) self.assertEqual(4, len(objs)) self.assertEqual(4, len(set(objs))) @@ -124,6 +147,7 @@ def test_create(self): self.assertEqual(obj.id, None) self.assertEqual(obj.foo, 'bar') + @unittest.skipIf(SKIP_DJANGO, "django tests disabled.") def test_create_custom_base(self): obj = factory.create(FakeModel, foo='bar', FACTORY_CLASS=factory.django.DjangoModelFactory) self.assertEqual(obj.id, 2) @@ -139,9 +163,14 @@ def test_create_batch(self): self.assertEqual(obj.id, None) self.assertEqual(obj.foo, 'bar') + @unittest.skipIf(SKIP_DJANGO, "django tests disabled.") def test_create_batch_custom_base(self): - objs = factory.create_batch(FakeModel, 4, foo='bar', - FACTORY_CLASS=factory.django.DjangoModelFactory) + objs = factory.create_batch( + FakeModel, + 4, + foo='bar', + FACTORY_CLASS=factory.django.DjangoModelFactory, + ) self.assertEqual(4, len(objs)) self.assertEqual(4, len(set(objs))) @@ -175,9 +204,14 @@ def test_generate_create(self): self.assertEqual(obj.id, None) self.assertEqual(obj.foo, 'bar') + @unittest.skipIf(SKIP_DJANGO, "django tests disabled.") def test_generate_create_custom_base(self): - obj = factory.generate(FakeModel, factory.CREATE_STRATEGY, foo='bar', - FACTORY_CLASS=factory.django.DjangoModelFactory) + obj = factory.generate( + FakeModel, + factory.CREATE_STRATEGY, + foo='bar', + FACTORY_CLASS=factory.django.DjangoModelFactory, + ) self.assertEqual(obj.id, 2) self.assertEqual(obj.foo, 'bar') @@ -206,9 +240,15 @@ def test_generate_batch_create(self): self.assertEqual(obj.id, None) self.assertEqual(obj.foo, 'bar') + @unittest.skipIf(SKIP_DJANGO, "django tests disabled.") def test_generate_batch_create_custom_base(self): - objs = factory.generate_batch(FakeModel, factory.CREATE_STRATEGY, 20, foo='bar', - FACTORY_CLASS=factory.django.DjangoModelFactory) + objs = factory.generate_batch( + FakeModel, + factory.CREATE_STRATEGY, + 20, + foo='bar', + FACTORY_CLASS=factory.django.DjangoModelFactory, + ) self.assertEqual(20, len(objs)) self.assertEqual(20, len(set(objs))) @@ -237,6 +277,7 @@ def test_simple_generate_create(self): self.assertEqual(obj.id, None) self.assertEqual(obj.foo, 'bar') + @unittest.skipIf(SKIP_DJANGO, "django tests disabled.") def test_simple_generate_create_custom_base(self): obj = factory.simple_generate(FakeModel, True, foo='bar', FACTORY_CLASS=factory.django.DjangoModelFactory) self.assertEqual(obj.id, 2) @@ -262,9 +303,15 @@ def test_simple_generate_batch_create(self): self.assertEqual(obj.id, None) self.assertEqual(obj.foo, 'bar') + @unittest.skipIf(SKIP_DJANGO, "django tests disabled.") def test_simple_generate_batch_create_custom_base(self): - objs = factory.simple_generate_batch(FakeModel, True, 20, foo='bar', - FACTORY_CLASS=factory.django.DjangoModelFactory) + objs = factory.simple_generate_batch( + FakeModel, + True, + 20, + foo='bar', + FACTORY_CLASS=factory.django.DjangoModelFactory, + ) self.assertEqual(20, len(objs)) self.assertEqual(20, len(set(objs))) @@ -288,41 +335,59 @@ def test_make_factory(self): self.assertEqual(obj.three, 5) self.assertEqual(obj.four, None) + def test_build_to_dict(self): + # We have a generic factory + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + one = 'one' + two = factory.LazyAttribute(lambda o: o.one * 2) + + # Now, get a dict out of it + obj = factory.build(dict, FACTORY_CLASS=TestObjectFactory) + self.assertEqual({'one': 'one', 'two': 'oneone'}, obj) + class UsingFactoryTestCase(unittest.TestCase): def test_attribute(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 'one' test_object = TestObjectFactory.build() self.assertEqual(test_object.one, 'one') - def test_inheriting_target_class(self): - @factory.use_strategy(factory.BUILD_STRATEGY) + def test_inheriting_model_class(self): class TestObjectFactory(factory.Factory, TestObject): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 'one' - test_object = TestObjectFactory() + test_object = TestObjectFactory.build() self.assertEqual(test_object.one, 'one') def test_abstract(self): class SomeAbstractFactory(factory.Factory): - ABSTRACT_FACTORY = True + class Meta: + abstract = True + one = 'one' class InheritedFactory(SomeAbstractFactory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject test_object = InheritedFactory.build() self.assertEqual(test_object.one, 'one') def test_sequence(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Sequence(lambda n: 'one%d' % n) two = factory.Sequence(lambda n: 'two%d' % n) @@ -337,7 +402,8 @@ class TestObjectFactory(factory.Factory): def test_sequence_custom_begin(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject @classmethod def _setup_next_sequence(cls): @@ -356,7 +422,8 @@ def _setup_next_sequence(cls): def test_sequence_override(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Sequence(lambda n: 'one%d' % n) @@ -372,13 +439,14 @@ class TestObjectFactory(factory.Factory): def test_custom_create(self): class TestModelFactory(factory.Factory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel two = 2 @classmethod - def _create(cls, target_class, *args, **kwargs): - obj = target_class.create(**kwargs) + def _create(cls, model_class, *args, **kwargs): + obj = model_class.create(**kwargs) obj.properly_created = True return obj @@ -389,13 +457,14 @@ def _create(cls, target_class, *args, **kwargs): self.assertTrue(obj.properly_created) def test_non_django_create(self): - class NonDjango(object): + class NonDjango: def __init__(self, x, y=2): self.x = x self.y = y class NonDjangoFactory(factory.Factory): - FACTORY_FOR = NonDjango + class Meta: + model = NonDjango x = 3 @@ -405,7 +474,8 @@ class NonDjangoFactory(factory.Factory): def test_sequence_batch(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Sequence(lambda n: 'one%d' % n) two = factory.Sequence(lambda n: 'two%d' % n) @@ -420,9 +490,10 @@ class TestObjectFactory(factory.Factory): def test_lazy_attribute(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject - one = factory.LazyAttribute(lambda a: 'abc' ) + one = factory.LazyAttribute(lambda a: 'abc') two = factory.LazyAttribute(lambda a: a.one + ' xyz') test_object = TestObjectFactory.build() @@ -431,7 +502,8 @@ class TestObjectFactory(factory.Factory): def test_lazy_attribute_sequence(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.LazyAttributeSequence(lambda a, n: 'abc%d' % n) two = factory.LazyAttributeSequence(lambda a, n: a.one + ' xyz%d' % n) @@ -446,7 +518,8 @@ class TestObjectFactory(factory.Factory): def test_lazy_attribute_decorator(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject @factory.lazy_attribute def one(a): @@ -456,11 +529,12 @@ def one(a): self.assertEqual(test_object.one, 'one') def test_self_attribute(self): - class TmpObj(object): + class TmpObj: n = 3 class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 'xx' two = factory.SelfAttribute('one') @@ -479,12 +553,14 @@ class TestModel2(FakeModel): pass class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 3 three = factory.SelfAttribute('..bar') class TestModel2Factory(FakeModelFactory): - FACTORY_FOR = TestModel2 + class Meta: + model = TestModel2 bar = 4 two = factory.SubFactory(TestModelFactory, one=1) @@ -493,7 +569,8 @@ class TestModel2Factory(FakeModelFactory): def test_sequence_decorator(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject @factory.sequence def one(n): @@ -504,11 +581,13 @@ def one(n): def test_lazy_attribute_sequence_decorator(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject @factory.lazy_attribute_sequence def one(a, n): return 'one%d' % n + @factory.lazy_attribute_sequence def two(a, n): return a.one + ' two%d' % n @@ -519,7 +598,8 @@ def two(a, n): def test_build_with_parameters(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Sequence(lambda n: 'one%d' % n) two = factory.Sequence(lambda n: 'two%d' % n) @@ -535,7 +615,8 @@ class TestObjectFactory(factory.Factory): def test_create(self): class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 'one' @@ -545,7 +626,8 @@ class TestModelFactory(FakeModelFactory): def test_create_batch(self): class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 'one' @@ -561,7 +643,8 @@ class TestModelFactory(FakeModelFactory): def test_generate_build(self): class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 'one' @@ -571,7 +654,8 @@ class TestModelFactory(FakeModelFactory): def test_generate_create(self): class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 'one' @@ -581,7 +665,8 @@ class TestModelFactory(FakeModelFactory): def test_generate_stub(self): class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 'one' @@ -591,7 +676,8 @@ class TestModelFactory(FakeModelFactory): def test_generate_batch_build(self): class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 'one' @@ -607,7 +693,8 @@ class TestModelFactory(FakeModelFactory): def test_generate_batch_create(self): class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 'one' @@ -623,7 +710,8 @@ class TestModelFactory(FakeModelFactory): def test_generate_batch_stub(self): class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 'one' @@ -639,7 +727,8 @@ class TestModelFactory(FakeModelFactory): def test_simple_generate_build(self): class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 'one' @@ -649,7 +738,8 @@ class TestModelFactory(FakeModelFactory): def test_simple_generate_create(self): class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 'one' @@ -659,7 +749,8 @@ class TestModelFactory(FakeModelFactory): def test_simple_generate_batch_build(self): class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 'one' @@ -675,7 +766,8 @@ class TestModelFactory(FakeModelFactory): def test_simple_generate_batch_create(self): class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 'one' @@ -691,14 +783,17 @@ class TestModelFactory(FakeModelFactory): def test_stub_batch(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 'one' two = factory.LazyAttribute(lambda a: a.one + ' two') three = factory.Sequence(lambda n: int(n)) - objs = TestObjectFactory.stub_batch(20, - one=factory.Sequence(lambda n: str(n))) + objs = TestObjectFactory.stub_batch( + 20, + one=factory.Sequence(lambda n: str(n)), + ) self.assertEqual(20, len(objs)) self.assertEqual(20, len(set(objs))) @@ -710,13 +805,15 @@ class TestObjectFactory(factory.Factory): def test_inheritance(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 'one' two = factory.LazyAttribute(lambda a: a.one + ' two') class TestObjectFactory2(TestObjectFactory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject three = 'three' four = factory.LazyAttribute(lambda a: a.three + ' four') @@ -730,15 +827,48 @@ class TestObjectFactory2(TestObjectFactory): test_object_alt = TestObjectFactory.build() self.assertEqual(None, test_object_alt.three) + def test_override_inherited(self): + """Overriding inherited declarations""" + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + one = 'one' + + class TestObjectFactory2(TestObjectFactory): + one = 'two' + + test_object = TestObjectFactory2.build() + self.assertEqual('two', test_object.one) + + def test_override_inherited_deep(self): + """Overriding inherited declarations""" + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + one = 'one' + + class TestObjectFactory2(TestObjectFactory): + one = 'two' + + class TestObjectFactory3(TestObjectFactory2): + pass + + test_object = TestObjectFactory3.build() + self.assertEqual('two', test_object.one) + def test_inheritance_and_sequences(self): """Sequence counters should be kept within an inheritance chain.""" class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Sequence(lambda n: n) class TestObjectFactory2(TestObjectFactory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject to1a = TestObjectFactory() self.assertEqual(0, to1a.one) @@ -755,12 +885,14 @@ class TestObject2(TestObject): pass class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Sequence(lambda n: n) class TestObjectFactory2(TestObjectFactory): - FACTORY_FOR = TestObject2 + class Meta: + model = TestObject2 to1a = TestObjectFactory() self.assertEqual(0, to1a.one) @@ -774,23 +906,25 @@ class TestObjectFactory2(TestObjectFactory): def test_inheritance_sequence_unrelated_objects(self): """Sequence counters are kept with inheritance, unrelated objects. - See issue https://github.com/rbarrois/factory_boy/issues/93 + See issue https://github.com/FactoryBoy/factory_boy/issues/93 Problem: sequence counter is somewhat shared between factories until the "slave" factory has been called. """ - class TestObject2(object): + class TestObject2: def __init__(self, one): self.one = one class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Sequence(lambda n: n) class TestObjectFactory2(TestObjectFactory): - FACTORY_FOR = TestObject2 + class Meta: + model = TestObject2 to1a = TestObjectFactory() self.assertEqual(0, to1a.one) @@ -801,10 +935,10 @@ class TestObjectFactory2(TestObjectFactory): to2b = TestObjectFactory2() self.assertEqual(1, to2b.one) - def test_inheritance_with_inherited_class(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 'one' two = factory.LazyAttribute(lambda a: a.one + ' two') @@ -821,12 +955,14 @@ class TestFactory(TestObjectFactory): def test_dual_inheritance(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 'one' class TestOtherFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject two = 'two' four = 'four' @@ -841,7 +977,8 @@ class TestFactory(TestObjectFactory, TestOtherFactory): def test_class_method_accessible(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject @classmethod def alt_create(cls, **kwargs): @@ -851,7 +988,8 @@ def alt_create(cls, **kwargs): def test_static_method_accessible(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject @staticmethod def alt_create(**kwargs): @@ -859,15 +997,16 @@ def alt_create(**kwargs): self.assertEqual(TestObjectFactory.alt_create(foo=1), {"foo": 1}) - def test_arg_parameters(self): - class TestObject(object): + def test_inline_args(self): + class TestObject: def __init__(self, *args, **kwargs): self.args = args self.kwargs = kwargs class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject - FACTORY_ARG_PARAMETERS = ('x', 'y') + class Meta: + model = TestObject + inline_args = ('x', 'y') x = 1 y = 2 @@ -878,15 +1017,16 @@ class TestObjectFactory(factory.Factory): self.assertEqual((42, 2), obj.args) self.assertEqual({'z': 5, 't': 4}, obj.kwargs) - def test_hidden_args(self): - class TestObject(object): + def test_exclude(self): + class TestObject: def __init__(self, *args, **kwargs): self.args = args self.kwargs = kwargs class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject - FACTORY_HIDDEN_ARGS = ('x', 'z') + class Meta: + model = TestObject + exclude = ('x', 'z') x = 1 y = 2 @@ -897,16 +1037,17 @@ class TestObjectFactory(factory.Factory): self.assertEqual((), obj.args) self.assertEqual({'y': 2, 't': 4}, obj.kwargs) - def test_hidden_args_and_arg_parameters(self): - class TestObject(object): + def test_exclude_and_inline_args(self): + class TestObject: def __init__(self, *args, **kwargs): self.args = args self.kwargs = kwargs class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject - FACTORY_HIDDEN_ARGS = ('x', 'z') - FACTORY_ARG_PARAMETERS = ('y',) + class Meta: + model = TestObject + exclude = ('x', 'z') + inline_args = ('y',) x = 1 y = 2 @@ -918,17 +1059,17 @@ class TestObjectFactory(factory.Factory): self.assertEqual({'t': 4}, obj.kwargs) - class NonKwargParametersTestCase(unittest.TestCase): def test_build(self): - class TestObject(object): + class TestObject: def __init__(self, *args, **kwargs): self.args = args self.kwargs = kwargs class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject - FACTORY_ARG_PARAMETERS = ('one', 'two',) + class Meta: + model = TestObject + inline_args = ('one', 'two',) one = 1 two = 2 @@ -939,7 +1080,7 @@ class TestObjectFactory(factory.Factory): self.assertEqual({'three': 3}, obj.kwargs) def test_create(self): - class TestObject(object): + class TestObject: def __init__(self, *args, **kwargs): self.args = None self.kwargs = None @@ -952,16 +1093,17 @@ def create(cls, *args, **kwargs): return inst class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject - FACTORY_ARG_PARAMETERS = ('one', 'two') + class Meta: + model = TestObject + inline_args = ('one', 'two') one = 1 two = 2 three = 3 @classmethod - def _create(cls, target_class, *args, **kwargs): - return target_class.create(*args, **kwargs) + def _create(cls, model_class, *args, **kwargs): + return model_class.create(*args, **kwargs) obj = TestObjectFactory.create() self.assertEqual((1, 2), obj.args) @@ -972,13 +1114,14 @@ class KwargAdjustTestCase(unittest.TestCase): """Tests for the _adjust_kwargs method.""" def test_build(self): - class TestObject(object): + class TestObject: def __init__(self, *args, **kwargs): self.args = args self.kwargs = kwargs class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject @classmethod def _adjust_kwargs(cls, **kwargs): @@ -989,6 +1132,357 @@ def _adjust_kwargs(cls, **kwargs): self.assertEqual({'x': 1, 'y': 2, 'z': 3, 'foo': 3}, obj.kwargs) self.assertEqual((), obj.args) + def test_rename(self): + class TestObject: + def __init__(self, attributes=None): + self.attributes = attributes + + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + rename = {'attributes_': 'attributes'} + + attributes_ = 42 + + obj = TestObjectFactory.build() + self.assertEqual(42, obj.attributes) + + def test_rename_non_existent_kwarg(self): + # see https://github.com/FactoryBoy/factory_boy/issues/504 + class TestObject: + def __init__(self, attributes=None): + self.attributes = attributes + + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + rename = {'form_attributes': 'attributes'} + + try: + TestObjectFactory() + except KeyError: + self.fail('should not raise KeyError for missing renamed attributes') + + +class MaybeTestCase(unittest.TestCase): + def test_simple_maybe(self): + class DummyFactory(factory.Factory): + class Meta: + model = Dummy + + # Undeclared: key = None + both = factory.Maybe('key', 1, 2) + yes = factory.Maybe('key', 1) + no = factory.Maybe('key', no_declaration=2) + none = factory.Maybe('key') + + obj_default = DummyFactory.build() + obj_true = DummyFactory.build(key=True) + obj_false = DummyFactory.build(key=False) + + self.assertEqual(dict(both=2, no=2), obj_default.as_dict) + self.assertEqual(dict(key=True, both=1, yes=1), obj_true.as_dict) + self.assertEqual(dict(key=False, both=2, no=2), obj_false.as_dict) + + def test_declarations(self): + class DummyFactory(factory.Factory): + class Meta: + model = Dummy + + a = 0 + b = 1 + + # biggest = 'b' if .b > .a else 'a' + biggest = factory.Maybe(factory.LazyAttribute(lambda o: o.a < o.b), 'b', 'a') + # max_value = .b if .b > .a else .a = max(.a, .b) + max_value = factory.Maybe( + factory.LazyAttribute(lambda o: o.a < o.b), + factory.SelfAttribute('b'), + factory.SelfAttribute('a'), + ) + + obj_ordered = DummyFactory.build(a=1, b=2) + obj_equal = DummyFactory.build(a=3, b=3) + obj_reverse = DummyFactory.build(a=5, b=4) + + self.assertEqual(dict(a=1, b=2, biggest='b', max_value=2, ), obj_ordered.as_dict) + self.assertEqual(dict(a=3, b=3, biggest='a', max_value=3, ), obj_equal.as_dict) + self.assertEqual(dict(a=5, b=4, biggest='a', max_value=5, ), obj_reverse.as_dict) + + def test_post_generation(self): + + # Helpers + @factory.post_generation + def square(obj, *args, **kwargs): + obj.value *= obj.value + + @factory.post_generation + def quintuple(obj, *args, **kwargs): + obj.value *= 5 + + @factory.post_generation + def double(obj, *args, **kwargs): + obj.value *= 2 + + @factory.post_generation + def decrement(obj, *args, **kwargs): + obj.value -= 1 + + class DummyFactory(factory.Factory): + class Meta: + model = Dummy + + value = 0 + square_it = factory.Maybe('square', square) + quintuple_it = factory.Maybe('quintuple', quintuple) + adjust_nums = factory.Maybe( + factory.LazyAttribute(lambda o: o.value % 2 == 0), + double, + decrement, + ) + + obj_untouched = DummyFactory.build(value=4) + obj_squared = DummyFactory.build(value=5, square=True) + obj_combined = DummyFactory.build(value=6, square=True, quintuple=True) + + self.assertEqual(4 * 2, obj_untouched.value) + self.assertEqual(5 ** 2 - 1, obj_squared.value) + self.assertEqual(6 ** 2 * 5 * 2, obj_combined.value) + + +class TraitTestCase(unittest.TestCase): + def test_traits(self): + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + class Params: + even = factory.Trait(two=True, four=True) + odd = factory.Trait(one=True, three=True, five=True) + + obj1 = TestObjectFactory() + self.assertEqual( + obj1.as_dict(), + dict(one=None, two=None, three=None, four=None, five=None), + ) + + obj2 = TestObjectFactory(even=True) + self.assertEqual( + obj2.as_dict(), + dict(one=None, two=True, three=None, four=True, five=None), + ) + + obj3 = TestObjectFactory(odd=True) + self.assertEqual( + obj3.as_dict(), + dict(one=True, two=None, three=True, four=None, five=True), + ) + + obj4 = TestObjectFactory(even=True, odd=True) + self.assertEqual( + obj4.as_dict(), + dict(one=True, two=True, three=True, four=True, five=True), + ) + + obj5 = TestObjectFactory(odd=True, two=True) + self.assertEqual( + obj5.as_dict(), + dict(one=True, two=True, three=True, four=None, five=True), + ) + + def test_post_generation_traits(self): + @factory.post_generation + def compute(obj, _create, _value, power=2, **kwargs): + obj.value = obj.value ** power + + class DummyFactory(factory.Factory): + class Meta: + model = Dummy + + value = 3 + + class Params: + exponentiate = factory.Trait(apply_exponent=compute) + + base = DummyFactory.build() + self.assertEqual(dict(value=3), base.as_dict) + + exp = DummyFactory.build(exponentiate=True) + self.assertEqual(dict(value=9), exp.as_dict) + + higher = DummyFactory.build(exponentiate=True, apply_exponent__power=4) + self.assertEqual(dict(value=81), higher.as_dict) + + def test_traits_inheritance(self): + """A trait can be set in an inherited class.""" + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + class Params: + even = factory.Trait(two=True, four=True) + odd = factory.Trait(one=True, three=True, five=True) + + class EvenObjectFactory(TestObjectFactory): + even = True + + # Simple call + obj1 = EvenObjectFactory() + self.assertEqual( + obj1.as_dict(), + dict(one=None, two=True, three=None, four=True, five=None), + ) + + # Force-disable it + obj2 = EvenObjectFactory(even=False) + self.assertEqual( + obj2.as_dict(), + dict(one=None, two=None, three=None, four=None, five=None), + ) + + def test_traits_override_params(self): + """Override a Params value in a trait""" + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + one = factory.LazyAttribute(lambda o: o.zero + 1) + + class Params: + zero = 0 + plus_one = factory.Trait(zero=1) + + obj = TestObjectFactory(plus_one=True) + self.assertEqual(obj.one, 2) + + def test_traits_override(self): + """Override a trait in a subclass.""" + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + class Params: + even = factory.Trait(two=True, four=True) + odd = factory.Trait(one=True, three=True, five=True) + + class WeirdMathFactory(TestObjectFactory): + class Params: + # Here, one is even. + even = factory.Trait(two=True, four=True, one=True) + + obj = WeirdMathFactory(even=True) + self.assertEqual( + obj.as_dict(), + dict(one=True, two=True, three=None, four=True, five=None), + ) + + def test_traits_chaining(self): + """Use a trait to enable other traits.""" + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + class Params: + even = factory.Trait(two=True, four=True) + odd = factory.Trait(one=True, three=True, five=True) + full = factory.Trait(even=True, odd=True) + override = factory.Trait(even=True, two=False) + + # Setting "full" should enable all fields. + obj = TestObjectFactory(full=True) + self.assertEqual( + obj.as_dict(), + dict(one=True, two=True, three=True, four=True, five=True), + ) + + # Does it break usual patterns? + obj1 = TestObjectFactory() + self.assertEqual( + obj1.as_dict(), + dict(one=None, two=None, three=None, four=None, five=None), + ) + + obj2 = TestObjectFactory(even=True) + self.assertEqual( + obj2.as_dict(), + dict(one=None, two=True, three=None, four=True, five=None), + ) + + obj3 = TestObjectFactory(odd=True) + self.assertEqual( + obj3.as_dict(), + dict(one=True, two=None, three=True, four=None, five=True), + ) + + # Setting override should override two and set it to False + obj = TestObjectFactory(override=True) + self.assertEqual( + obj.as_dict(), + dict(one=None, two=False, three=None, four=True, five=None), + ) + + def test_prevent_cyclic_traits(self): + + with self.assertRaises(errors.CyclicDefinitionError): + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + class Params: + a = factory.Trait(b=True, one=True) + b = factory.Trait(a=True, two=True) + + def test_deep_traits(self): + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + class WrapperFactory(factory.Factory): + class Meta: + model = TestObject + + class Params: + deep_one = factory.Trait( + one=1, + two__one=2, + ) + + two = factory.SubFactory(TestObjectFactory) + + wrapper = WrapperFactory(deep_one=True) + self.assertEqual(1, wrapper.one) + self.assertEqual(2, wrapper.two.one) + + def test_traits_and_postgeneration(self): + """A trait parameter should be resolved before post_generation hooks. + + See https://github.com/FactoryBoy/factory_boy/issues/466. + """ + PRICES = {} + + Pizza = collections.namedtuple('Pizza', ['style', 'toppings']) + + class PizzaFactory(factory.Factory): + class Meta: + model = Pizza + + class Params: + fancy = factory.Trait( + toppings=['eggs', 'ham', 'extra_cheese'], + pricing__extra=10, + ) + + pricing__extra = 0 + toppings = ['tomato', 'cheese'] + style = 'margharita' + + @factory.post_generation + def pricing(self, create, extracted, base_price=5, extra=0, **kwargs): + PRICES[base_price + extra] = self + + p = PizzaFactory.build() + self.assertEqual({5: p}, PRICES) + class SubFactoryTestCase(unittest.TestCase): def test_sub_factory(self): @@ -996,11 +1490,13 @@ class TestModel2(FakeModel): pass class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 3 class TestModel2Factory(FakeModelFactory): - FACTORY_FOR = TestModel2 + class Meta: + model = TestModel2 two = factory.SubFactory(TestModelFactory, one=1) test_model = TestModel2Factory(two__one=4) @@ -1013,32 +1509,58 @@ class TestModel2(FakeModel): pass class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel class TestModel2Factory(FakeModelFactory): - FACTORY_FOR = TestModel2 - two = factory.SubFactory(TestModelFactory, + class Meta: + model = TestModel2 + two = factory.SubFactory( + TestModelFactory, one=factory.Sequence(lambda n: 'x%dx' % n), - two=factory.LazyAttribute(lambda o: '%s%s' % (o.one, o.one)), + two=factory.LazyAttribute(lambda o: f'{o.one}{o.one}'), ) test_model = TestModel2Factory(one=42) self.assertEqual('x0x', test_model.two.one) self.assertEqual('x0xx0x', test_model.two.two) + def test_sub_factory_with_lazy_fields_access_factory_parent(self): + class TestModel2(FakeModel): + pass + + class TestModelFactory(FakeModelFactory): + class Meta: + model = TestModel + one = 3 + + class TestModel2Factory(FakeModelFactory): + class Meta: + model = TestModel2 + one = 'parent' + child = factory.SubFactory( + TestModelFactory, + one=factory.LazyAttribute(lambda o: '%s child' % o.factory_parent.one), + ) + + test_model = TestModel2Factory() + self.assertEqual('parent child', test_model.child.one) + def test_sub_factory_and_sequence(self): - class TestObject(object): + class TestObject: def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Sequence(lambda n: int(n)) class WrappingTestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject wrapped = factory.SubFactory(TestObjectFactory) @@ -1048,22 +1570,23 @@ class WrappingTestObjectFactory(factory.Factory): self.assertEqual(1, wrapping.wrapped.one) def test_sub_factory_overriding(self): - class TestObject(object): + class TestObject: def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject - + class Meta: + model = TestObject - class OtherTestObject(object): + class OtherTestObject: def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) class WrappingTestObjectFactory(factory.Factory): - FACTORY_FOR = OtherTestObject + class Meta: + model = OtherTestObject wrapped = factory.SubFactory(TestObjectFactory, two=2, four=4) wrapped__two = 4 @@ -1074,25 +1597,55 @@ class WrappingTestObjectFactory(factory.Factory): self.assertEqual(wrapping.wrapped.three, 3) self.assertEqual(wrapping.wrapped.four, 4) + def test_sub_factory_deep_overrides(self): + Author = collections.namedtuple('Author', ['name', 'country']) + Book = collections.namedtuple('Book', ['title', 'author']) + Chapter = collections.namedtuple('Chapter', ['book', 'number']) + + class AuthorFactory(factory.Factory): + class Meta: + model = Author + name = "John" + country = 'XX' + + class BookFactory(factory.Factory): + class Meta: + model = Book + title = "The mighty adventures of nobody." + author = factory.SubFactory(AuthorFactory) + + class ChapterFactory(factory.Factory): + class Meta: + model = Chapter + book = factory.SubFactory(BookFactory) + number = factory.Sequence(lambda n: n) + book__author__country = factory.LazyAttribute(lambda o: 'FR') + + chapter = ChapterFactory() + self.assertEqual('FR', chapter.book.author.country) + def test_nested_sub_factory(self): """Test nested sub-factories.""" - class TestObject(object): + class TestObject: def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject class WrappingTestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject wrapped = factory.SubFactory(TestObjectFactory) wrapped_bis = factory.SubFactory(TestObjectFactory, one=1) class OuterWrappingTestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject wrap = factory.SubFactory(WrappingTestObjectFactory, wrapped__two=2) @@ -1103,26 +1656,31 @@ class OuterWrappingTestObjectFactory(factory.Factory): def test_nested_sub_factory_with_overridden_sub_factories(self): """Test nested sub-factories, with attributes overridden with subfactories.""" - class TestObject(object): + class TestObject: def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject two = 'two' class WrappingTestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject wrapped = factory.SubFactory(TestObjectFactory) friend = factory.LazyAttribute(lambda o: o.wrapped.two.four + 1) class OuterWrappingTestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject - wrap = factory.SubFactory(WrappingTestObjectFactory, - wrapped__two=factory.SubFactory(TestObjectFactory, four=4)) + wrap = factory.SubFactory( + WrappingTestObjectFactory, + wrapped__two=factory.SubFactory(TestObjectFactory, four=4), + ) outer = OuterWrappingTestObjectFactory.build() self.assertEqual(outer.wrap.wrapped.two.four, 4) @@ -1132,19 +1690,21 @@ def test_nested_subfactory_with_override(self): """Tests replacing a SubFactory field with an actual value.""" # The test class - class TestObject(object): + class TestObject: def __init__(self, two='one', wrapped=None): self.two = two self.wrapped = wrapped # Innermost factory class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject two = 'two' # Intermediary factory class WrappingTestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject wrapped = factory.SubFactory(TestObjectFactory) wrapped__two = 'three' @@ -1154,19 +1714,69 @@ class WrappingTestObjectFactory(factory.Factory): self.assertEqual(obj, outer.wrapped) self.assertEqual('four', outer.wrapped.two) + def test_deep_nested_subfactory(self): + counter = iter(range(100)) + + class Node: + def __init__(self, label, child=None): + self.id = next(counter) + self.label = label + self.child = child + + class LeafFactory(factory.Factory): + class Meta: + model = Node + label = 'leaf' + + class BranchFactory(factory.Factory): + class Meta: + model = Node + label = 'branch' + child = factory.SubFactory(LeafFactory) + + class TreeFactory(factory.Factory): + class Meta: + model = Node + label = 'tree' + child = factory.SubFactory(BranchFactory) + child__child__label = 'magic-leaf' + + leaf = LeafFactory() + # Magic corruption did happen here once: + # forcing child__child=X while another part already set another value + # on child__child__label meant that the value passed for child__child + # was merged into the factory's inner declaration dict. + mtree_1 = TreeFactory(child__child=leaf) + mtree_2 = TreeFactory() + + self.assertEqual(0, mtree_1.child.child.id) + self.assertEqual('leaf', mtree_1.child.child.label) + self.assertEqual(1, mtree_1.child.id) + self.assertEqual('branch', mtree_1.child.label) + self.assertEqual(2, mtree_1.id) + self.assertEqual('tree', mtree_1.label) + self.assertEqual(3, mtree_2.child.child.id) + self.assertEqual('magic-leaf', mtree_2.child.child.label) + self.assertEqual(4, mtree_2.child.id) + self.assertEqual('branch', mtree_2.child.label) + self.assertEqual(5, mtree_2.id) + self.assertEqual('tree', mtree_2.label) + def test_sub_factory_and_inheritance(self): """Test inheriting from a factory with subfactories, overriding.""" - class TestObject(object): + class TestObject: def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject two = 'two' class WrappingTestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject wrapped = factory.SubFactory(TestObjectFactory) friend = factory.LazyAttribute(lambda o: o.wrapped.two + 1) @@ -1180,46 +1790,58 @@ class ExtendedWrappingTestObjectFactory(WrappingTestObjectFactory): def test_diamond_sub_factory(self): """Tests the case where an object has two fields with a common field.""" - class InnerMost(object): + class InnerMost: def __init__(self, a, b): self.a = a self.b = b - class SideA(object): + class SideA: def __init__(self, inner_from_a): self.inner_from_a = inner_from_a - class SideB(object): + class SideB: def __init__(self, inner_from_b): self.inner_from_b = inner_from_b - class OuterMost(object): + class OuterMost: def __init__(self, foo, side_a, side_b): self.foo = foo self.side_a = side_a self.side_b = side_b class InnerMostFactory(factory.Factory): - FACTORY_FOR = InnerMost + class Meta: + model = InnerMost a = 15 b = 20 class SideAFactory(factory.Factory): - FACTORY_FOR = SideA + class Meta: + model = SideA inner_from_a = factory.SubFactory(InnerMostFactory, a=20) class SideBFactory(factory.Factory): - FACTORY_FOR = SideB + class Meta: + model = SideB inner_from_b = factory.SubFactory(InnerMostFactory, b=15) class OuterMostFactory(factory.Factory): - FACTORY_FOR = OuterMost + class Meta: + model = OuterMost foo = 30 - side_a = factory.SubFactory(SideAFactory, - inner_from_a__a=factory.ContainerAttribute(lambda obj, containers: containers[1].foo * 2)) - side_b = factory.SubFactory(SideBFactory, - inner_from_b=factory.ContainerAttribute(lambda obj, containers: containers[0].side_a.inner_from_a)) + side_a = factory.SubFactory( + SideAFactory, + inner_from_a__a=factory.ContainerAttribute( + lambda obj, containers: containers[1].foo * 2, + ) + ) + side_b = factory.SubFactory( + SideBFactory, + inner_from_b=factory.ContainerAttribute( + lambda obj, containers: containers[0].side_a.inner_from_a, + ) + ) outer = OuterMostFactory.build() self.assertEqual(outer.foo, 30) @@ -1227,7 +1849,7 @@ class OuterMostFactory(factory.Factory): self.assertEqual(outer.side_a.inner_from_a.a, outer.foo * 2) self.assertEqual(outer.side_a.inner_from_a.b, 20) - outer = OuterMostFactory.build(side_a__inner_from_a__b = 4) + outer = OuterMostFactory.build(side_a__inner_from_a__b=4) self.assertEqual(outer.foo, 30) self.assertEqual(outer.side_a.inner_from_a, outer.side_b.inner_from_b) self.assertEqual(outer.side_a.inner_from_a.a, outer.foo * 2) @@ -1238,12 +1860,14 @@ class TestModel2(FakeModel): pass class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel one = 3 two = factory.ContainerAttribute(lambda obj, containers: len(containers or []), strict=False) class TestModel2Factory(FakeModelFactory): - FACTORY_FOR = TestModel2 + class Meta: + model = TestModel2 one = 1 two = factory.SubFactory(TestModelFactory, one=1) @@ -1261,56 +1885,62 @@ class TestModel2(FakeModel): pass class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel - one = 3 - two = factory.ContainerAttribute(lambda obj, containers: len(containers or []), strict=True) + class Meta: + model = TestModel + sample_int = 3 + container_len = factory.ContainerAttribute(lambda obj, containers: len(containers or []), strict=True) class TestModel2Factory(FakeModelFactory): - FACTORY_FOR = TestModel2 - one = 1 - two = factory.SubFactory(TestModelFactory, one=1) + class Meta: + model = TestModel2 + sample_int = 1 + descendant = factory.SubFactory(TestModelFactory, sample_int=1) obj = TestModel2Factory.build() - self.assertEqual(1, obj.one) - self.assertEqual(1, obj.two.one) - self.assertEqual(1, obj.two.two) + self.assertEqual(1, obj.sample_int) + self.assertEqual(1, obj.descendant.sample_int) + self.assertEqual(1, obj.descendant.container_len) - self.assertRaises(TypeError, TestModelFactory.build) + with self.assertRaises(TypeError): + TestModelFactory.build() def test_function_container_attribute(self): class TestModel2(FakeModel): pass class TestModelFactory(FakeModelFactory): - FACTORY_FOR = TestModel - one = 3 + class Meta: + model = TestModel + sample_int = 3 @factory.container_attribute - def two(self, containers): + def container_len(self, containers): if containers: return len(containers) return 42 class TestModel2Factory(FakeModelFactory): - FACTORY_FOR = TestModel2 - one = 1 - two = factory.SubFactory(TestModelFactory, one=1) + class Meta: + model = TestModel2 + sample_int = 1 + descendant = factory.SubFactory(TestModelFactory, sample_int=1) obj = TestModel2Factory.build() - self.assertEqual(1, obj.one) - self.assertEqual(1, obj.two.one) - self.assertEqual(1, obj.two.two) + self.assertEqual(1, obj.sample_int) + self.assertEqual(1, obj.descendant.sample_int) + self.assertEqual(1, obj.descendant.container_len) obj = TestModelFactory() - self.assertEqual(3, obj.one) - self.assertEqual(42, obj.two) + self.assertEqual(3, obj.sample_int) + self.assertEqual(42, obj.container_len) class IteratorTestCase(unittest.TestCase): def test_iterator(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Iterator(range(10, 30)) @@ -1319,22 +1949,11 @@ class TestObjectFactory(factory.Factory): for i, obj in enumerate(objs): self.assertEqual(i + 10, obj.one) - @unittest.skipUnless(is_python2, "Scope bleeding fixed in Python3+") - @tools.disable_warnings - def test_iterator_list_comprehension_scope_bleeding(self): - class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject - - one = factory.Iterator([j * 3 for j in range(5)]) - - # Scope bleeding: j will end up in TestObjectFactory's scope. - - self.assertRaises(TypeError, TestObjectFactory.build) - - @tools.disable_warnings + @utils.disable_warnings def test_iterator_list_comprehension_protected(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Iterator([_j * 3 for _j in range(5)]) @@ -1347,20 +1966,68 @@ class TestObjectFactory(factory.Factory): def test_iterator_decorator(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject @factory.iterator def one(): - for i in range(10, 50): # pragma: no cover - yield i + yield from range(10, 50) objs = TestObjectFactory.build_batch(20) for i, obj in enumerate(objs): self.assertEqual(i + 10, obj.one) + def test_iterator_late_loading(self): + """Ensure that Iterator doesn't unroll on class creation. + + This allows, for Django objects, to call: + foo = factory.Iterator(models.MyThingy.objects.all()) + """ + class DBRequest: + def __init__(self): + self.ready = False + + def __iter__(self): + if not self.ready: + raise ValueError("Not ready!!") + return iter([1, 2, 3]) + + # calling __iter__() should crash + req1 = DBRequest() + with self.assertRaises(ValueError): + iter(req1) + + req2 = DBRequest() + + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + one = factory.Iterator(req2) + + req2.ready = True + obj = TestObjectFactory() + self.assertEqual(1, obj.one) + + def test_iterator_time_manipulation(self): + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + @factory.iterator + def one(): + now = datetime.datetime.now() + yield now + datetime.timedelta(hours=1) + yield now + datetime.timedelta(hours=2) + + obj1, obj2, obj3 = TestObjectFactory.create_batch(3) + # Timers should be t+1H, t+2H, t+1H, t+2H, etc. + self.assertEqual(datetime.timedelta(hours=1), obj2.one - obj1.one) + self.assertEqual(obj1.one, obj3.one) -class BetterFakeModelManager(object): + +class BetterFakeModelManager: def __init__(self, keys, instance): self.keys = keys self.instance = instance @@ -1374,14 +2041,11 @@ def get_or_create(self, **kwargs): instance.id = 2 return instance, True - def values_list(self, *args, **kwargs): + def using(self, db): return self - def order_by(self, *args, **kwargs): - return [1] - -class BetterFakeModel(object): +class BetterFakeModel: @classmethod def create(cls, **kwargs): instance = cls(**kwargs) @@ -1394,10 +2058,12 @@ def __init__(self, **kwargs): self.id = None +@unittest.skipIf(SKIP_DJANGO, "django tests disabled.") class DjangoModelFactoryTestCase(unittest.TestCase): def test_simple(self): class FakeModelFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = FakeModel + class Meta: + model = FakeModel obj = FakeModelFactory(one=1) self.assertEqual(1, obj.one) @@ -1411,8 +2077,9 @@ class MyFakeModel(BetterFakeModel): objects = BetterFakeModelManager({'x': 1}, prev) class MyFakeModelFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = MyFakeModel - FACTORY_DJANGO_GET_OR_CREATE = ('x',) + class Meta: + model = MyFakeModel + django_get_or_create = ('x',) x = 1 y = 4 z = 6 @@ -1432,8 +2099,9 @@ class MyFakeModel(BetterFakeModel): objects = BetterFakeModelManager({'x': 1, 'y': 2, 'z': 3}, prev) class MyFakeModelFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = MyFakeModel - FACTORY_DJANGO_GET_OR_CREATE = ('x', 'y', 'z') + class Meta: + model = MyFakeModel + django_get_or_create = ('x', 'y', 'z') x = 1 y = 4 z = 6 @@ -1453,8 +2121,9 @@ class MyFakeModel(BetterFakeModel): objects = BetterFakeModelManager({'x': 1}, prev) class MyFakeModelFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = MyFakeModel - FACTORY_DJANGO_GET_OR_CREATE = ('x',) + class Meta: + model = MyFakeModel + django_get_or_create = ('x',) x = 1 y = 4 z = 6 @@ -1474,8 +2143,9 @@ class MyFakeModel(BetterFakeModel): objects = BetterFakeModelManager({'x': 1, 'y': 2, 'z': 3}, prev) class MyFakeModelFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = MyFakeModel - FACTORY_DJANGO_GET_OR_CREATE = ('x', 'y', 'z') + class Meta: + model = MyFakeModel + django_get_or_create = ('x', 'y', 'z') x = 1 y = 4 z = 6 @@ -1489,37 +2159,40 @@ class MyFakeModelFactory(factory.django.DjangoModelFactory): def test_sequence(self): class TestModelFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel a = factory.Sequence(lambda n: 'foo_%s' % n) o1 = TestModelFactory() o2 = TestModelFactory() - self.assertEqual('foo_2', o1.a) - self.assertEqual('foo_3', o2.a) + self.assertEqual('foo_0', o1.a) + self.assertEqual('foo_1', o2.a) o3 = TestModelFactory.build() o4 = TestModelFactory.build() - self.assertEqual('foo_4', o3.a) - self.assertEqual('foo_5', o4.a) + self.assertEqual('foo_2', o3.a) + self.assertEqual('foo_3', o4.a) def test_no_get_or_create(self): class TestModelFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = TestModel + class Meta: + model = TestModel a = factory.Sequence(lambda n: 'foo_%s' % n) o = TestModelFactory() self.assertEqual(None, o._defaults) - self.assertEqual('foo_2', o.a) + self.assertEqual('foo_0', o.a) self.assertEqual(2, o.id) def test_get_or_create(self): class TestModelFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = TestModel - FACTORY_DJANGO_GET_OR_CREATE = ('a', 'b') + class Meta: + model = TestModel + django_get_or_create = ('a', 'b') a = factory.Sequence(lambda n: 'foo_%s' % n) b = 2 @@ -1528,7 +2201,7 @@ class TestModelFactory(factory.django.DjangoModelFactory): o = TestModelFactory() self.assertEqual({'c': 3, 'd': 4}, o._defaults) - self.assertEqual('foo_2', o.a) + self.assertEqual('foo_0', o.a) self.assertEqual(2, o.b) self.assertEqual(3, o.c) self.assertEqual(4, o.d) @@ -1537,8 +2210,9 @@ class TestModelFactory(factory.django.DjangoModelFactory): def test_full_get_or_create(self): """Test a DjangoModelFactory with all fields in get_or_create.""" class TestModelFactory(factory.django.DjangoModelFactory): - FACTORY_FOR = TestModel - FACTORY_DJANGO_GET_OR_CREATE = ('a', 'b', 'c', 'd') + class Meta: + model = TestModel + django_get_or_create = ('a', 'b', 'c', 'd') a = factory.Sequence(lambda n: 'foo_%s' % n) b = 2 @@ -1547,7 +2221,7 @@ class TestModelFactory(factory.django.DjangoModelFactory): o = TestModelFactory() self.assertEqual({}, o._defaults) - self.assertEqual('foo_2', o.a) + self.assertEqual('foo_0', o.a) self.assertEqual(2, o.b) self.assertEqual(3, o.c) self.assertEqual(4, o.d) @@ -1557,7 +2231,8 @@ class TestModelFactory(factory.django.DjangoModelFactory): class PostGenerationTestCase(unittest.TestCase): def test_post_generation(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 1 @@ -1575,7 +2250,8 @@ def incr_one(self, _create, _increment): def test_post_generation_hook(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 1 @@ -1596,7 +2272,8 @@ def _after_postgeneration(cls, obj, create, results): def test_post_generation_extraction(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 1 @@ -1621,16 +2298,46 @@ def my_lambda(obj, create, extracted, **kwargs): self.assertEqual(kwargs, {'foo': 13}) class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject bar = factory.PostGeneration(my_lambda) - obj = TestObjectFactory.build(bar=42, bar__foo=13) + TestObjectFactory.build(bar=42, bar__foo=13) - def test_post_generation_method_call(self): - calls = [] + def test_post_generation_override_with_extra(self): + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + one = 1 - class TestObject(object): + @factory.post_generation + def incr_one(self, _create, override, **extra): + multiplier = extra.get('multiplier', 1) + if override is None: + override = 1 + self.one += override * multiplier + + obj = TestObjectFactory.build() + self.assertEqual(1 + 1 * 1, obj.one) + obj = TestObjectFactory.build(incr_one=2) + self.assertEqual(1 + 2 * 1, obj.one) + obj = TestObjectFactory.build(incr_one__multiplier=4) + self.assertEqual(1 + 1 * 4, obj.one) + obj = TestObjectFactory.build(incr_one=2, incr_one__multiplier=5) + self.assertEqual(1 + 2 * 5, obj.one) + + # Passing extras through inherited params + class OtherTestObjectFactory(TestObjectFactory): + class Params: + incr_one__multiplier = 4 + + obj = OtherTestObjectFactory.build() + self.assertEqual(1 + 1 * 4, obj.one) + + def test_post_generation_method_call(self): + class TestObject: def __init__(self, one=None, two=None): self.one = one self.two = two @@ -1640,7 +2347,8 @@ def call(self, *args, **kwargs): self.extra = (args, kwargs) class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 3 two = 2 post_call = factory.PostGenerationMethodCall('call', one=1) @@ -1655,8 +2363,29 @@ class TestObjectFactory(factory.Factory): self.assertEqual(2, obj.two) self.assertEqual(((), {'one': 2, 'two': 3}), obj.extra) + def test_post_generation_extraction_declaration(self): + LIBRARY = {} + + Book = collections.namedtuple('Book', ['author']) + + class BookFactory(factory.Factory): + class Meta: + model = Book + + author = factory.Faker('name') + register__reference = factory.Sequence(lambda n: n) + + @factory.post_generation + def register(self, create, extracted, reference=0, **kwargs): + LIBRARY[reference] = self + + book = BookFactory.build() + self.assertEqual({0: book}, LIBRARY) + + +class RelatedFactoryTestCase(unittest.TestCase): def test_related_factory(self): - class TestRelatedObject(object): + class TestRelatedObject: def __init__(self, obj=None, one=None, two=None): obj.related = self self.one = one @@ -1664,15 +2393,20 @@ def __init__(self, obj=None, one=None, two=None): self.three = obj class TestRelatedObjectFactory(factory.Factory): - FACTORY_FOR = TestRelatedObject + class Meta: + model = TestRelatedObject one = 1 two = factory.LazyAttribute(lambda o: o.one + 1) class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 3 two = 2 - three = factory.RelatedFactory(TestRelatedObjectFactory, name='obj') + three = factory.RelatedFactory( + TestRelatedObjectFactory, + factory_related_name='obj', + ) obj = TestObjectFactory.build() # Normal fields @@ -1701,7 +2435,8 @@ class TestObjectFactory(factory.Factory): def test_related_factory_no_name(self): relateds = [] - class TestRelatedObject(object): + + class TestRelatedObject: def __init__(self, obj=None, one=None, two=None): relateds.append(self) self.one = one @@ -1709,12 +2444,14 @@ def __init__(self, obj=None, one=None, two=None): self.three = obj class TestRelatedObjectFactory(factory.Factory): - FACTORY_FOR = TestRelatedObject + class Meta: + model = TestRelatedObject one = 1 two = factory.LazyAttribute(lambda o: o.one + 1) class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 3 two = 2 three = factory.RelatedFactory(TestRelatedObjectFactory) @@ -1743,23 +2480,228 @@ class TestObjectFactory(factory.Factory): self.assertEqual(3, related.one) self.assertEqual(4, related.two) + def test_related_factory_selfattribute(self): + class TestRelatedObject: + def __init__(self, obj=None, one=None, two=None): + obj.related = self + self.one = one + self.two = two + self.three = obj + + class TestRelatedObjectFactory(factory.Factory): + class Meta: + model = TestRelatedObject + one = 1 + two = factory.LazyAttribute(lambda o: o.one + 1) + + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + one = 3 + two = 2 + three = factory.RelatedFactory( + TestRelatedObjectFactory, + factory_related_name='obj', + two=factory.SelfAttribute('obj.two'), + ) + + obj = TestObjectFactory.build(two=4) + self.assertEqual(3, obj.one) + self.assertEqual(4, obj.two) + self.assertEqual(1, obj.related.one) + self.assertEqual(4, obj.related.two) + + def test_parameterized_related_factory(self): + class TestRelatedObject: + def __init__(self, obj=None, one=None, two=None): + obj.related = self + self.one = one + self.two = two + self.related = obj + + class TestRelatedObjectFactory(factory.Factory): + class Meta: + model = TestRelatedObject + one = 1 + two = factory.LazyAttribute(lambda o: o.one + 1) + + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + + class Params: + blah = 1 + + one = 3 + two = 2 + three = factory.RelatedFactory( + TestRelatedObjectFactory, + factory_related_name='obj', + ) + three__two = factory.SelfAttribute('..blah') + + obj = TestObjectFactory.build() + self.assertEqual(3, obj.one) + self.assertEqual(2, obj.two) + self.assertEqual(1, obj.related.one) + self.assertEqual(1, obj.related.two) + + obj2 = TestObjectFactory.build(blah='blah') + self.assertEqual('blah', obj2.related.two) + + +class RelatedListFactoryTestCase(unittest.TestCase): + def test_related_factory_list_of_varying_size(self): + # Create our list of expected "related object counts" + related_list_sizes = [5, 5, 4, 4, 3, 3, 2, 2, 1, 1] + RELATED_LIST_SIZE = lambda: related_list_sizes.pop() + + class TestRelatedObject: + def __init__(self, obj=None, one=None, two=None): + # Mock out the 'List of Related Objects' generated by RelatedFactoryList + if hasattr(obj, 'related_list'): + obj.related_list.append(self) + else: + obj.related_list = [self] + self.one = one + self.two = two + self.three = obj + + class TestRelatedObjectFactoryList(factory.Factory): + class Meta: + model = TestRelatedObject + one = 1 + two = factory.LazyAttribute(lambda o: o.one + 1) + + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + one = 3 + two = 2 + # RELATED_LIST_SIZE is a lambda, this allows flexibility, as opposed + # to creating "n" related objects for every parent object... + three = factory.RelatedFactoryList(TestRelatedObjectFactoryList, + 'obj', + size=RELATED_LIST_SIZE) + # Create 5 TestObjectFactories: Each with 1, 2, ... 5 related objs + for related_list_size in reversed(related_list_sizes[1::2]): + obj = TestObjectFactory.build() + # Normal fields + self.assertEqual(3, obj.one) + self.assertEqual(2, obj.two) + # RelatedFactory was built + self.assertIsNone(obj.three) + self.assertIsNotNone(obj.related_list) + + for related_obj in obj.related_list: + self.assertEqual(1, related_obj.one) + self.assertEqual(2, related_obj.two) + # Each RelatedFactory in the RelatedFactoryList was passed the "parent" object + self.assertEqual(related_list_size, len(obj.related_list)) + # obj.related is the list of TestRelatedObject(s) + for related_obj in obj.related_list: + self.assertEqual(obj, related_obj.three) + + obj = TestObjectFactory.build(three__one=3) + # Normal fields + self.assertEqual(3, obj.one) + self.assertEqual(2, obj.two) + # RelatedFactory was build + self.assertIsNone(obj.three) + self.assertIsNotNone(obj.related_list) + # three__one was correctly parse + for related_obj in obj.related_list: + self.assertEqual(3, related_obj.one) + self.assertEqual(4, related_obj.two) + # Each RelatedFactory in RelatedFactoryList received "parent" object + self.assertEqual(related_list_size, len(obj.related_list)) + for related_obj in obj.related_list: + self.assertEqual(obj, related_obj.three) + + def test_related_factory_list_of_static_size(self): + RELATED_LIST_SIZE = 4 + + class TestRelatedObject: + def __init__(self, obj=None, one=None, two=None): + # Mock out the 'List of Related Objects' generated by RelatedFactoryList + if hasattr(obj, 'related_list'): + obj.related_list.append(self) + else: + obj.related_list = [self] + + self.one = one + self.two = two + self.three = obj + + class TestRelatedObjectFactoryList(factory.Factory): + class Meta: + model = TestRelatedObject + one = 1 + two = factory.LazyAttribute(lambda o: o.one + 1) + + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + one = 3 + two = 2 + three = factory.RelatedFactoryList(TestRelatedObjectFactoryList, 'obj', + size=RELATED_LIST_SIZE) + + obj = TestObjectFactory.build() + # Normal fields + self.assertEqual(3, obj.one) + self.assertEqual(2, obj.two) + # RelatedFactory was built + self.assertIsNone(obj.three) + self.assertIsNotNone(obj.related_list) + + for related_obj in obj.related_list: + self.assertEqual(1, related_obj.one) + self.assertEqual(2, related_obj.two) + # Each RelatedFactory in the RelatedFactoryList was passed the "parent" object + self.assertEqual(RELATED_LIST_SIZE, len(obj.related_list)) + # obj.related is the list of TestRelatedObject(s) + for related_obj in obj.related_list: + self.assertEqual(obj, related_obj.three) + + obj = TestObjectFactory.build(three__one=3) + # Normal fields + self.assertEqual(3, obj.one) + self.assertEqual(2, obj.two) + # RelatedFactory was build + self.assertIsNone(obj.three) + self.assertIsNotNone(obj.related_list) + # three__one was correctly parse + for related_obj in obj.related_list: + self.assertEqual(3, related_obj.one) + self.assertEqual(4, related_obj.two) + # Each RelatedFactory in RelatedFactoryList received "parent" object + self.assertEqual(RELATED_LIST_SIZE, len(obj.related_list)) + for related_obj in obj.related_list: + self.assertEqual(obj, related_obj.three) + class RelatedFactoryExtractionTestCase(unittest.TestCase): def setUp(self): self.relateds = [] - class TestRelatedObject(object): + class TestRelatedObject: def __init__(subself, obj): self.relateds.append(subself) subself.obj = obj obj.related = subself class TestRelatedObjectFactory(factory.Factory): - FACTORY_FOR = TestRelatedObject + class Meta: + model = TestRelatedObject class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject - one = factory.RelatedFactory(TestRelatedObjectFactory, 'obj') + class Meta: + model = TestObject + one = factory.RelatedFactory( + TestRelatedObjectFactory, + factory_related_name='obj', + ) self.TestRelatedObject = TestRelatedObject self.TestRelatedObjectFactory = TestRelatedObjectFactory @@ -1802,10 +2744,52 @@ def test_example(self): self.assertIsNone(b.foo.bar.foo.bar) +class RepeatableRandomSeedFakerTests(unittest.TestCase): + def test_same_seed_is_used_between_fuzzy_and_faker_generators(self): + class StudentFactory(factory.Factory): + one = factory.fuzzy.FuzzyDecimal(4.0) + two = factory.Faker('name') + three = factory.Faker('name', locale='it') + four = factory.Faker('name') + + class Meta: + model = TestObject + + seed = 1000 + factory.random.reseed_random(seed) + students_1 = (StudentFactory(), StudentFactory()) + + factory.random.reseed_random(seed) + students_2 = (StudentFactory(), StudentFactory()) + + self.assertEqual(students_1[0].one, students_2[0].one) + self.assertEqual(students_1[0].two, students_2[0].two) + self.assertEqual(students_1[0].three, students_2[0].three) + self.assertEqual(students_1[0].four, students_2[0].four) + + +class SelfReferentialTests(unittest.TestCase): + def test_no_parent(self): + from .cyclic import self_ref + + obj = self_ref.TreeElementFactory(parent__parent__parent=None) + self.assertIsNone(obj.parent.parent.parent) + + def test_deep(self): + from .cyclic import self_ref + + obj = self_ref.TreeElementFactory(parent__parent__parent__parent=None) + self.assertIsNotNone(obj.parent) + self.assertIsNotNone(obj.parent.parent) + self.assertIsNotNone(obj.parent.parent.parent) + self.assertIsNone(obj.parent.parent.parent.parent) + + class DictTestCase(unittest.TestCase): def test_empty_dict(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Dict({}) o = TestObjectFactory() @@ -1813,7 +2797,8 @@ class TestObjectFactory(factory.Factory): def test_naive_dict(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Dict({'a': 1}) o = TestObjectFactory() @@ -1821,7 +2806,8 @@ class TestObjectFactory(factory.Factory): def test_sequence_dict(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Dict({'a': factory.Sequence(lambda n: n + 2)}) o1 = TestObjectFactory() @@ -1832,7 +2818,8 @@ class TestObjectFactory(factory.Factory): def test_dict_override(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Dict({'a': 1}) o = TestObjectFactory(one__a=2) @@ -1840,7 +2827,8 @@ class TestObjectFactory(factory.Factory): def test_dict_extra_key(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.Dict({'a': 1}) o = TestObjectFactory(one__b=2) @@ -1848,7 +2836,8 @@ class TestObjectFactory(factory.Factory): def test_dict_merged_fields(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject two = 13 one = factory.Dict({ 'one': 1, @@ -1861,7 +2850,8 @@ class TestObjectFactory(factory.Factory): def test_nested_dicts(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = 1 two = factory.Dict({ 'one': 3, @@ -1889,7 +2879,8 @@ class TestObjectFactory(factory.Factory): class ListTestCase(unittest.TestCase): def test_empty_list(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.List([]) o = TestObjectFactory() @@ -1897,15 +2888,26 @@ class TestObjectFactory(factory.Factory): def test_naive_list(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.List([1]) o = TestObjectFactory() self.assertEqual([1], o.one) + def test_long_list(self): + class TestObjectFactory(factory.Factory): + class Meta: + model = TestObject + one = factory.List(list(range(100))) + + o = TestObjectFactory() + self.assertEqual(list(range(100)), o.one) + def test_sequence_list(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.List([factory.Sequence(lambda n: n + 2)]) o1 = TestObjectFactory() @@ -1916,7 +2918,8 @@ class TestObjectFactory(factory.Factory): def test_list_override(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.List([1]) o = TestObjectFactory(one__0=2) @@ -1924,7 +2927,8 @@ class TestObjectFactory(factory.Factory): def test_list_extra_key(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject one = factory.List([1]) o = TestObjectFactory(one__1=2) @@ -1932,7 +2936,8 @@ class TestObjectFactory(factory.Factory): def test_list_merged_fields(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject two = 13 one = factory.List([ 1, @@ -1945,7 +2950,9 @@ class TestObjectFactory(factory.Factory): def test_nested_lists(self): class TestObjectFactory(factory.Factory): - FACTORY_FOR = TestObject + class Meta: + model = TestObject + one = 1 two = factory.List([ 3, @@ -1968,7 +2975,3 @@ class TestObjectFactory(factory.Factory): 1, ], ], o.two) - - -if __name__ == '__main__': # pragma: no cover - unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py index 8c739350..1d54eefe 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,221 +1,11 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. + import itertools +import unittest from factory import utils -from .compat import unittest - - -class ExtractDictTestCase(unittest.TestCase): - def test_empty_dict(self): - self.assertEqual({}, utils.extract_dict('foo', {})) - - def test_unused_key(self): - self.assertEqual({}, utils.extract_dict('foo', {'bar__baz': 42})) - - def test_empty_key(self): - self.assertEqual({}, utils.extract_dict('', {'foo': 13, 'bar__baz': 42})) - d = {'foo': 13, 'bar__baz': 42, '__foo': 1} - self.assertEqual({'foo': 1}, utils.extract_dict('', d)) - self.assertNotIn('__foo', d) - - def test_one_key(self): - d = {'foo': 13, 'foo__baz': 42, '__foo': 1} - self.assertEqual({'baz': 42}, utils.extract_dict('foo', d, pop=False)) - self.assertEqual(42, d['foo__baz']) - - self.assertEqual({'baz': 42}, utils.extract_dict('foo', d, pop=True)) - self.assertNotIn('foo__baz', d) - - def test_one_key_excluded(self): - d = {'foo': 13, 'foo__baz': 42, '__foo': 1} - self.assertEqual({}, - utils.extract_dict('foo', d, pop=False, exclude=('foo__baz',))) - self.assertEqual(42, d['foo__baz']) - - self.assertEqual({}, - utils.extract_dict('foo', d, pop=True, exclude=('foo__baz',))) - self.assertIn('foo__baz', d) - - def test_many_keys(self): - d = {'foo': 13, 'foo__baz': 42, 'foo__foo__bar': 2, 'foo__bar': 3, '__foo': 1} - self.assertEqual({'foo__bar': 2, 'bar': 3, 'baz': 42}, - utils.extract_dict('foo', d, pop=False)) - self.assertEqual(42, d['foo__baz']) - self.assertEqual(3, d['foo__bar']) - self.assertEqual(2, d['foo__foo__bar']) - - self.assertEqual({'foo__bar': 2, 'bar': 3, 'baz': 42}, - utils.extract_dict('foo', d, pop=True)) - self.assertNotIn('foo__baz', d) - self.assertNotIn('foo__bar', d) - self.assertNotIn('foo__foo__bar', d) - - def test_many_keys_excluded(self): - d = {'foo': 13, 'foo__baz': 42, 'foo__foo__bar': 2, 'foo__bar': 3, '__foo': 1} - self.assertEqual({'foo__bar': 2, 'baz': 42}, - utils.extract_dict('foo', d, pop=False, exclude=('foo__bar', 'bar'))) - self.assertEqual(42, d['foo__baz']) - self.assertEqual(3, d['foo__bar']) - self.assertEqual(2, d['foo__foo__bar']) - - self.assertEqual({'foo__bar': 2, 'baz': 42}, - utils.extract_dict('foo', d, pop=True, exclude=('foo__bar', 'bar'))) - self.assertNotIn('foo__baz', d) - self.assertIn('foo__bar', d) - self.assertNotIn('foo__foo__bar', d) - - -class MultiExtractDictTestCase(unittest.TestCase): - def test_empty_dict(self): - self.assertEqual({'foo': {}}, utils.multi_extract_dict(['foo'], {})) - - def test_unused_key(self): - self.assertEqual({'foo': {}}, - utils.multi_extract_dict(['foo'], {'bar__baz': 42})) - self.assertEqual({'foo': {}, 'baz': {}}, - utils.multi_extract_dict(['foo', 'baz'], {'bar__baz': 42})) - - def test_no_key(self): - self.assertEqual({}, utils.multi_extract_dict([], {'bar__baz': 42})) - - def test_empty_key(self): - self.assertEqual({'': {}}, - utils.multi_extract_dict([''], {'foo': 13, 'bar__baz': 42})) - - d = {'foo': 13, 'bar__baz': 42, '__foo': 1} - self.assertEqual({'': {'foo': 1}}, - utils.multi_extract_dict([''], d)) - self.assertNotIn('__foo', d) - - def test_one_extracted(self): - d = {'foo': 13, 'foo__baz': 42, '__foo': 1} - self.assertEqual({'foo': {'baz': 42}}, - utils.multi_extract_dict(['foo'], d, pop=False)) - self.assertEqual(42, d['foo__baz']) - - self.assertEqual({'foo': {'baz': 42}}, - utils.multi_extract_dict(['foo'], d, pop=True)) - self.assertNotIn('foo__baz', d) - - def test_many_extracted(self): - d = {'foo': 13, 'foo__baz': 42, 'foo__foo__bar': 2, 'foo__bar': 3, '__foo': 1} - self.assertEqual({'foo': {'foo__bar': 2, 'bar': 3, 'baz': 42}}, - utils.multi_extract_dict(['foo'], d, pop=False)) - self.assertEqual(42, d['foo__baz']) - self.assertEqual(3, d['foo__bar']) - self.assertEqual(2, d['foo__foo__bar']) - - self.assertEqual({'foo': {'foo__bar': 2, 'bar': 3, 'baz': 42}}, - utils.multi_extract_dict(['foo'], d, pop=True)) - self.assertNotIn('foo__baz', d) - self.assertNotIn('foo__bar', d) - self.assertNotIn('foo__foo__bar', d) - - def test_many_keys_one_extracted(self): - d = {'foo': 13, 'foo__baz': 42, '__foo': 1} - self.assertEqual({'foo': {'baz': 42}, 'baz': {}}, - utils.multi_extract_dict(['foo', 'baz'], d, pop=False)) - self.assertEqual(42, d['foo__baz']) - - self.assertEqual({'foo': {'baz': 42}, 'baz': {}}, - utils.multi_extract_dict(['foo', 'baz'], d, pop=True)) - self.assertNotIn('foo__baz', d) - - def test_many_keys_many_extracted(self): - d = { - 'foo': 13, - 'foo__baz': 42, 'foo__foo__bar': 2, 'foo__bar': 3, - 'bar__foo': 1, 'bar__bar__baz': 4, - } - - self.assertEqual( - { - 'foo': {'foo__bar': 2, 'bar': 3, 'baz': 42}, - 'bar': {'foo': 1, 'bar__baz': 4}, - 'baz': {} - }, - utils.multi_extract_dict(['foo', 'bar', 'baz'], d, pop=False)) - self.assertEqual(42, d['foo__baz']) - self.assertEqual(3, d['foo__bar']) - self.assertEqual(2, d['foo__foo__bar']) - self.assertEqual(1, d['bar__foo']) - self.assertEqual(4, d['bar__bar__baz']) - - self.assertEqual( - { - 'foo': {'foo__bar': 2, 'bar': 3, 'baz': 42}, - 'bar': {'foo': 1, 'bar__baz': 4}, - 'baz': {} - }, - utils.multi_extract_dict(['foo', 'bar', 'baz'], d, pop=True)) - self.assertNotIn('foo__baz', d) - self.assertNotIn('foo__bar', d) - self.assertNotIn('foo__foo__bar', d) - self.assertNotIn('bar__foo', d) - self.assertNotIn('bar__bar__baz', d) - - def test_son_in_list(self): - """Make sure that prefixes are used in decreasing match length order.""" - d = { - 'foo': 13, - 'foo__baz': 42, 'foo__foo__bar': 2, 'foo__bar': 3, - 'bar__foo': 1, 'bar__bar__baz': 4, - } - - self.assertEqual( - { - 'foo__foo': {'bar': 2}, - 'foo': {'bar': 3, 'baz': 42}, - 'bar__bar': {'baz': 4}, - 'bar': {'foo': 1}, - 'baz': {} - }, - utils.multi_extract_dict( - ['foo', 'bar', 'baz', 'foo__foo', 'bar__bar'], d, pop=False)) - self.assertEqual(42, d['foo__baz']) - self.assertEqual(3, d['foo__bar']) - self.assertEqual(2, d['foo__foo__bar']) - self.assertEqual(1, d['bar__foo']) - self.assertEqual(4, d['bar__bar__baz']) - - self.assertEqual( - { - 'foo__foo': {'bar': 2}, - 'foo': {'bar': 3, 'baz': 42}, - 'bar__bar': {'baz': 4}, - 'bar': {'foo': 1}, - 'baz': {} - }, - utils.multi_extract_dict( - ['foo', 'bar', 'baz', 'foo__foo', 'bar__bar'], d, pop=True)) - self.assertNotIn('foo__baz', d) - self.assertNotIn('foo__bar', d) - self.assertNotIn('foo__foo__bar', d) - self.assertNotIn('bar__foo', d) - self.assertNotIn('bar__bar__baz', d) - class ImportObjectTestCase(unittest.TestCase): def test_datetime(self): @@ -225,12 +15,48 @@ def test_datetime(self): self.assertEqual(d, imported) def test_unknown_attribute(self): - self.assertRaises(AttributeError, utils.import_object, - 'datetime', 'foo') + with self.assertRaises(AttributeError): + utils.import_object('datetime', 'foo') def test_invalid_module(self): - self.assertRaises(ImportError, utils.import_object, - 'this-is-an-invalid-module', '__name__') + with self.assertRaises(ImportError): + utils.import_object('this-is-an-invalid-module', '__name__') + + +class LogPPrintTestCase(unittest.TestCase): + def test_nothing(self): + txt = str(utils.log_pprint()) + self.assertEqual('', txt) + + def test_only_args(self): + txt = str(utils.log_pprint((1, 2, 3))) + self.assertEqual('1, 2, 3', txt) + + def test_only_kwargs(self): + txt = str(utils.log_pprint(kwargs={'a': 1, 'b': 2})) + self.assertIn(txt, ['a=1, b=2', 'b=2, a=1']) + + def test_bytes_args(self): + txt = str(utils.log_pprint((b'\xe1\xe2',))) + expected = "b'\\xe1\\xe2'" + self.assertEqual(expected, txt) + + def test_text_args(self): + txt = str(utils.log_pprint(('ŧêßŧ',))) + expected = "'ŧêßŧ'" + self.assertEqual(expected, txt) + + def test_bytes_kwargs(self): + txt = str(utils.log_pprint(kwargs={'x': b'\xe1\xe2', 'y': b'\xe2\xe1'})) + expected1 = "x=b'\\xe1\\xe2', y=b'\\xe2\\xe1'" + expected2 = "y=b'\\xe2\\xe1', x=b'\\xe1\\xe2'" + self.assertIn(txt, (expected1, expected2)) + + def test_text_kwargs(self): + txt = str(utils.log_pprint(kwargs={'x': 'ŧêßŧ', 'y': 'ŧßêŧ'})) + expected1 = "x='ŧêßŧ', y='ŧßêŧ'" + expected2 = "y='ŧßêŧ', x='ŧêßŧ'" + self.assertIn(txt, (expected1, expected2)) class ResetableIteratorTestCase(unittest.TestCase): @@ -291,7 +117,8 @@ def test_reset_after_end(self): self.assertEqual(1, next(iterator)) self.assertEqual(2, next(iterator)) self.assertEqual(3, next(iterator)) - self.assertRaises(StopIteration, next, iterator) + with self.assertRaises(StopIteration): + next(iterator) i.reset() # Previous iter() has stopped @@ -335,4 +162,3 @@ def test_reset_shorter(self): self.assertEqual(2, next(iterator)) self.assertEqual(3, next(iterator)) self.assertEqual(4, next(iterator)) - diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 00000000..4fceda4a --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,20 @@ +# Copyright: See the LICENSE file. + +import pathlib +import unittest + +import factory + +SETUP_CFG_VERSION_PREFIX = "version =" + + +class VersionTestCase(unittest.TestCase): + def get_setupcfg_version(self): + setup_cfg_path = pathlib.Path(__file__).parent.parent / "setup.cfg" + with setup_cfg_path.open("r") as f: + for line in f: + if line.startswith(SETUP_CFG_VERSION_PREFIX): + return line[len(SETUP_CFG_VERSION_PREFIX):].strip() + + def test_version(self): + self.assertEqual(factory.__version__, self.get_setupcfg_version()) diff --git a/tests/testdata/__init__.py b/tests/testdata/__init__.py index 99566104..b0de791a 100644 --- a/tests/testdata/__init__.py +++ b/tests/testdata/__init__.py @@ -1,23 +1,4 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# Copyright: See the LICENSE file. import os.path diff --git a/tests/tools.py b/tests/tools.py deleted file mode 100644 index 571899bc..00000000 --- a/tests/tools.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - - -import functools -import warnings - - -def disable_warnings(fun): - @functools.wraps(fun) - def decorated(*args, **kwargs): - with warnings.catch_warnings(): - warnings.simplefilter('ignore') - return fun(*args, **kwargs) - return decorated - - diff --git a/tests/utils.py b/tests/utils.py index 215fc83a..8c62a018 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,36 +1,27 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2010 Mark Sandstrom -# Copyright (c) 2011-2013 Raphaël Barrois -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -import datetime - -from .compat import mock +# Copyright: See the LICENSE file. + +import functools +import warnings + +import factory + from . import alter_time -class MultiModulePatcher(object): +def disable_warnings(fun): + @functools.wraps(fun) + def decorated(*args, **kwargs): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + return fun(*args, **kwargs) + return decorated + + +class MultiModulePatcher: """An abstract context processor for patching multiple modules.""" def __init__(self, *target_modules, **kwargs): - super(MultiModulePatcher, self).__init__(**kwargs) + super().__init__(**kwargs) self.patchers = [self._build_patcher(mod) for mod in target_modules] def _build_patcher(self, target_module): # pragma: no cover @@ -39,7 +30,7 @@ def _build_patcher(self, target_module): # pragma: no cover def __enter__(self): for patcher in self.patchers: - mocked_symbol = patcher.start() + patcher.start() def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): for patcher in self.patchers: @@ -51,7 +42,7 @@ class mocked_date_today(MultiModulePatcher): def __init__(self, target_date, *target_modules, **kwargs): self.target_date = target_date - super(mocked_date_today, self).__init__(*target_modules, **kwargs) + super().__init__(*target_modules, **kwargs) def _build_patcher(self, target_module): module_datetime = getattr(target_module, 'datetime') @@ -61,8 +52,16 @@ def _build_patcher(self, target_module): class mocked_datetime_now(MultiModulePatcher): def __init__(self, target_dt, *target_modules, **kwargs): self.target_dt = target_dt - super(mocked_datetime_now, self).__init__(*target_modules, **kwargs) + super().__init__(*target_modules, **kwargs) def _build_patcher(self, target_module): module_datetime = getattr(target_module, 'datetime') return alter_time.mock_datetime_now(self.target_dt, module_datetime) + + +def evaluate_declaration(declaration, force_sequence=None): + kwargs = {'attr': declaration} + if force_sequence is not None: + kwargs['__sequence'] = force_sequence + + return factory.build(dict, **kwargs)['attr'] diff --git a/tox.ini b/tox.ini index 2200f56a..5d6bb14d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,17 +1,74 @@ [tox] -envlist = py26,py27,pypy +minversion = 1.9 +envlist = + lint + docs + examples + linkcheck + py{39,310,311,312,313,py39,py310,py311} + py{39,310,311,312,313}-django42-mongo-alchemy + py{py310,py311}-django42-mongo-alchemy + py{310,311,312,313}-django51-mongo-alchemy + pypy310-django51-mongo-alchemy + py310-djangomain-mongo-alchemy + +[gh-actions] +python = + 3.9: py39 + 3.10: py310 + 3.11: py311 + 3.12: py312 + 3.13: py313 + pypy-3.10: pypy310 + pypy-3.11: pypy311 [testenv] -commands= - python -W default setup.py test +deps = + mypy + alchemy: SQLAlchemy + mongo: mongoengine + mongo: mongomock + # mongomock imports pkg_resources, provided by setuptools. + mongo: setuptools>=66.1.1 + django{42,51,52,main}: Pillow + django42: Django>=4.2,<5.0 + django51: Django>=5.1,<5.2 + django52: Django>=5.2,<6 + djangomain: Django>5.1,<6.0 + +setenv = + py: DJANGO_SETTINGS_MODULE=tests.djapp.settings + +pip_pre = + djangomain: true + +allowlist_externals = make +commands = make test + +[testenv:docs] +extras = doc + +whitelist_externals = make +commands = make doc spelling + +[testenv:examples] +deps = + -rexamples/requirements.txt + +whitelist_externals = make +commands = make example-test -[testenv:py26] +[testenv:linkcheck] +extras = doc -deps= - mock - unittest2 +whitelist_externals = make +commands = make linkcheck -[textenv:py27] +[testenv:lint] +deps = + -rexamples/requirements.txt + check_manifest +extras = dev -deps= - mock +whitelist_externals = make +commands = make lint