Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Infinite Recursion possible in 2.4 #220

Closed
dave-v opened this issue Sep 30, 2022 · 41 comments · May be fixed by #240
Closed

Infinite Recursion possible in 2.4 #220

dave-v opened this issue Sep 30, 2022 · 41 comments · May be fixed by #240

Comments

@dave-v
Copy link

dave-v commented Sep 30, 2022

We have a condition_dict containing the following function:

def need_further_steps(wizard):
    data = wizard.get_cleaned_data_for_step("0") or {}
    if data.get("no_invoices_needed", False):
        return False
    return True

The call to WizardView.get_cleaned_data_for_step then calls WizardView.get_form, which used to reference self.form_list, but the following commit changes this to self.get_form_list(), which then calls the user-supplied condition_dict functions, which then calls our need_further_steps function above, which calls WizardView.get_cleaned_data_for_step, and so on ad infinitum.
533a830

Perhaps our custom condition function is bad, but it doesn't seem too unreasonable?

@claudep
Copy link
Contributor

claudep commented Sep 30, 2022

@stuaxo, @schinckel, any idea about how to avoid that regression?

@schinckel
Copy link
Contributor

I think I’ve had similar issues. I’ll try to have a look later tonight to see how I got around it.

@schinckel
Copy link
Contributor

Okay, I've found the similar problem I came across. I think it's the "same" problem, just a different approach.

I had code in get_form_list that needed to see cleaned data from other steps, which triggers the infinite co-recursion.

My workaround was to get the raw data instead, and build up that form and get the cleaned data that way.

We should think about either/both a way to handle this (getting cleaned data from other steps in condition_dict callable), and/or documenting the problem.

@schinckel
Copy link
Contributor

schinckel commented Oct 1, 2022

Ah, looking at the code, it seems that whilst get_cleaned_data_for_step(step) doesn't use get_form_list(), it uses get_form, which does.

As an aside, I think for consistency, get_cleaned_data_for_step should use get_form_list.

But that doesn't solve this problem (it just moves it to a different place).

@schinckel
Copy link
Contributor

I wonder if it's possible to use a generator that retains knowledge of previous steps: it should be possible for condition_dict to refer to previous steps, but not subsequent steps.

Let me see what I can get to work.

schinckel added a commit to schinckel/django-formtools that referenced this issue Oct 1, 2022
This shows a possible approach we can use to fix the infinite recursion that can be triggered by referring to step data from condition_dict callables.
@stuaxo
Copy link
Contributor

stuaxo commented Oct 2, 2022

Maybe get_cleaned_data_step could be behind lru_cache with maxsize None.

bcvandendool added a commit to CosmosTUe/Cosmos that referenced this issue Oct 5, 2022
Issue with infinite recursion in version 2.4, see jazzband/django-formtools#220
@violuke
Copy link

violuke commented Oct 18, 2022

We're also having the same issue

@DJJacobs
Copy link

Experiencing the same issue upon update from 2.3 > 2.4. Reverting back to 2.3 for now. Following for updates...

@violuke
Copy link

violuke commented Oct 19, 2022

The fix of #168 has caused this problem. My understanding is that you should be using the conditiion_dict to vary which steps are in the wizard and which are not. I can see why overriding get_form_list() would seem like a nice alternative (and maybe simpler to use), but by changing get_form() to use get_form_list() and solve the request of #168, this makes it impossible to have conditions which look at prior form steps as part of their conditions and therefore breaks what is probably one of the most common use-cases of this package.

My opinion is that 533a830 should be reverted as it tries to allow the same problem to be solved in a new, previously-not-possible, way and breaks the original way of solving the same problem that's been part of this great package for years.

Thanks for your help, we've also had to pin 2.3 due to this being a breaking change for our use of the package.

violuke added a commit to violuke/django-formtools that referenced this issue Oct 19, 2022
@spapas
Copy link

spapas commented Oct 19, 2022

I have a different opinion on this. By being able to override the get_form_list you have complete freedom on the list of forms you're going to display in your wizard. See here some example code from a project where a wizard is created dynamically based on some configuration (taken from the PR_ACTION list of dics):

class PilotRequestActionView(..., SessionWizardView):
    ...

    def get_form_list(self):
        form_list = OrderedDict()
        action = self.kwargs["action"]
        has_form = [x for x in PR_ACTIONS if x["code"] == action][0]["has_form"]
        if has_form:
            form_class_str = (
                "PilotRequest"
                + "".join(x.capitalize() for x in action.split("_"))
                + "Form"
            )
            form_class = getattr(forms, form_class_str)
            form_list[action] = form_class
        form_list["submit_req"] = forms.PilotRequestSubmitForm
        return form_list

So I can do something like

path(
        "act/<str:action>/<int:pk>/",
        views.ActionView.as_view(),
        name="req-action",
    ),

in my urls and create different wizards.

I really don't think that something like that would possible using the condition_dict.

@schinckel
Copy link
Contributor

Yeah, the original use case for my patch (and what I've been using as a subclass for many years) was where I needed to add steps dynamically, based upon the submitted data from an earlier step. Specifically, there was a form that needed to be re-used N times, with different initial data.

@schinckel
Copy link
Contributor

So the fix I have (linked in this thread), it "works", but there's not a real way to detect that we have attempted to reference data from a subsequent step (and are inside an infinite recursion), until the Python infinite recursion protection kicks in by running out of stack: at that point we can't recover because, well, we are out of stack.

If there was a neat way of detecting we are in a recursive call, then we could try that - but without sniffing the stack, I wasn't able to detect that.

@stuaxo
Copy link
Contributor

stuaxo commented Oct 19, 2022

It probably makes sense to step back and list the places ways the data is referenced at different steps.

jsma added a commit to jsma/django-formtools that referenced this issue Nov 3, 2022
This removes the undocumented `get_form_list` since the process of repeatedly and dynamically generating an OrderedDict of steps/forms pairs leads to infinite recursion.
This is replaced with a new method `process_condition_dict` which directly modifies `form_list` in place, in a single step executed prior to dispatching to `get` or `post`.
This eliminates infinite recursion since the `form_list` is calculated exactly once prior to any other attempts to access and process forms.
Removed `test_form_condition_unstable` since this is an odd test (why would a condition_dict return value suddenly change in the middle of a request/response cycle?) that was attempting to deal with invalid steps (invalid steps are better handled through actual validation, see jazzband#224).
For users who need to dynamically _add_ forms, this can be handled by overriding `process_condition_dict`.
@tilsche
Copy link

tilsche commented Nov 11, 2022

We also have this issue that blocks us from upgrading.

@stuaxo
Copy link
Contributor

stuaxo commented Nov 11, 2022

@schinckel if you want to check if we're in a recursive call you can have a counter starting at 0.
One entry to the method increment it, and on exit decrement it.

If, on the way in the counter is not 0 then you are in a recursive call.

fdemmer added a commit to fdemmer/django-formtools that referenced this issue Nov 17, 2022
* greenlightgo/fix-infinite-recursion-220:
  Fix infinite recursion error jazzband#220
@jsma
Copy link
Contributor

jsma commented Nov 17, 2022

Please revert 533a830, which introduced a major bug and was not a proper solution for the new feature request (to dynamically add forms that aren't in the initial form_list). For those that require this new feature, please provide a working example of your use cases and how you were doing this prior to 2.4. A proper API needs to be created and tested for this new feature request, without breaking existing documented functionality. That has not been done yet. A proper API shouldn't require any hacks around infinite recursion errors, it should be designed so these errors are impossible.

@violuke
Copy link

violuke commented Nov 21, 2022

@claudep, @felixxm and @hramezani (I believe you're the Jazzband leads on this project) would it be possible for someone to review this thread? There was a major breaking change in 2.4 and a solution needs to be found if possible ASAP. Thank you.

@schinckel
Copy link
Contributor

Maybe we should revert the change, and work to find a solution that works later. I just don’t have the time to spend on it now.

@martey
Copy link

martey commented Nov 21, 2022

I'm confused about why people are advocating for this change to be reverted "ASAP". Is there a reason why affected people can't stick with 2.3 until a comprehensive solution for this issue is found?

@jsma
Copy link
Contributor

jsma commented Nov 22, 2022

@martey, it should be reverted because 2.4 is a broken release that breaks real projects and costs people valuable time and money. It's not very reasonable to expect everyone to find out the hard way that 2.4 is broken, find this issue, and then downgrade to 2.3, when reverting could avoid all of that. To be clear, we don't need to come up with a comprehensive solution for the infinite recursion bug. The changeset that introduced the bug simply needs to be reverted. Once that is done, a comprehensive solution to the feature request (to be able to dynamically add forms that are not present in form_list) would need to be developed, documented, and tested before being included in a future release.

@schinckel, are you saying you don't have time for reverting the commit or just that you do not have time to work on a proper solution for the feature request? There is already #222 for reverting the change. In my opinion, 2.4 should be yanked from PyPI in favor of the next release. I could submit a PR to update the changelog. Let us know how we can help. Thanks!

@schinckel
Copy link
Contributor

The latter - to come up with a proper solution.

@martey
Copy link

martey commented Nov 22, 2022

@jsma I don't think it's fair to assume that everyone uses django-formtools in the same way, or that everyone is affected by this regression. I think people using condition_dict callables that call get_cleaned_data_for_step (who are affected by this bug) and people using dynamic forms with get_form_list both have valid use cases.

Once that is done, a comprehensive solution to the feature request (to be able to dynamically add forms that are not present in form_list) would need to be developed, documented, and tested before being included in a future release.

This already happened (see #62, #168, #209). The "future release" was 2.4.

Calling this a "new feature" or "feature request" is inaccurate, especially since people have been confused by the fact that form_list is not generated from get_form_list for at least a decade (e.g. this Stack Overflow question from 2012), probably because the method's docstring has always suggested that this is the case.

In my opinion, 2.4 should be yanked from PyPI in favor of the next release.

Removing 2.4 from PyPI and releasing a new version would prevent people using dynamic forms with get_form_list from pinning 2.4. I think this would make things worse, not better.

@stuaxo
Copy link
Contributor

stuaxo commented Nov 22, 2022

I'll have a stare at this later + try and understand it (and invite everyone else to) - I (or someone else), should add a test to this for modifying get_form_list() as well so we have both cases.

EDIT: Thanks for making an actual test; I'm pressed for free time, so it's really helpful to have a working example to start from.

@stuaxo
Copy link
Contributor

stuaxo commented Nov 22, 2022

Gave feedback on the PR - since it involves a few different test improvements; it would be make it easier to merge if the fixes for different things are split into small standalone PRs.

It'll make it easier for people here evaluate the tests that is just for this issue.

@fdemmer
Copy link

fdemmer commented Nov 22, 2022

I think test improvements are dearly needed in this project; my PR is meant as a general improvement of things not just this issue.

Anyway, for the issue at hand you could pull just this commit: c6d9eda ... remove the skip() and implement "dynamic_form_list" in a way, that does not break it.

@neilmb
Copy link

neilmb commented Nov 23, 2022

I would like to use a condition_dict with callable values that access data from previous steps as described in https://django-formtools.readthedocs.io/en/latest/wizard.html#formtools.wizard.views.WizardView.condition_dict. This doesn't seem to work in the 2.4 release because of infinite recursion errors.

What are my options right now? I've thought of:

  • Pin a version (2.3?) of this library from before the regression happened
  • Get step data without calling cleaned_data_for_step(), perhaps by accessing storage.get_step_data directly
  • Wait until a fix for this regression is developed, approved, merged, and released

Are there other options for being able to use this feature that I'm not thinking of?

@spapas
Copy link

spapas commented Nov 23, 2022

@neilmb the other option is to override get_form of your class like this (see this method https://github.com/jazzband/django-formtools/blob/master/formtools/wizard/views.py#L391 ; I have changed it to not use the get_form_list() method):

    def get_form(self, step=None, data=None, files=None):
        if step is None:
            step = self.steps.current
        form_class = self.form_list[step]
        kwargs = self.get_form_kwargs(step)
        kwargs.update({
            'data': data,
            'files': files,
            'prefix': self.get_form_prefix(step, form_class),
            'initial': self.get_form_initial(step),
        })
        if issubclass(form_class, (forms.ModelForm, forms.models.BaseInlineFormSet)):
            kwargs.setdefault('instance', self.get_form_instance(step))
        elif issubclass(form_class, forms.models.BaseModelFormSet):
            kwargs.setdefault('queryset', self.get_form_instance(step))
        return form_class(**kwargs)

@JanMalte
Copy link

I'm confused about why people are advocating for this change to be reverted "ASAP". Is there a reason why affected people can't stick with 2.3 until a comprehensive solution for this issue is found?

A reason could be that version 2.4.0 added official support for django v4

Maybe a version 2.3.1 could be released with the old behaviour and official django v4 support?
So we can pin to <2.4 until the issue is somehow resolved and upgrade/use django v4.

@violuke
Copy link

violuke commented Nov 30, 2022

And in an ideal world, SemVer would be used so people could understand version numbers and use modern package managers like Poetry properly. This being a breaking change could then be safely introduced using version 3.0.0 instead of 2.4.0, if it was wanted (not that I'm sure it should be tbh). Having come from other languages to Python in the last few years, the Python community seems quite out of sync with the rest on the SemVer front and SemVer is really damn handy when managing dependencies.

@stuaxo
Copy link
Contributor

stuaxo commented Nov 30, 2022

OK, so I had a look back at the original PR and a bit of a think -

There are two use cases, one is an API for callers outside the wizard to get a list of forms, and the case where inside get_form we need a list.

If these have separate names we can avoid the issue.

@schinckel
Copy link
Contributor

schinckel commented Nov 30, 2022 via email

@stuaxo
Copy link
Contributor

stuaxo commented Nov 30, 2022

In that case is it enough to keep it and people use the solution @spapas proposes if people want to overide get_form ?

@sagesharp
Copy link

Hi folks! We are also being bitten by this infinite recursion bug. There was an ask for examples of forms with this issue. I don't have the simplest example (as was the ask), but you can take a look at our usage case in the Outreachy website repository. I've documented steps to reproduce the issue in the Outreachy website issue tracker

Outreachy is an internship program that supports diversity in open source. Outreachy uses django-formtools to have potential interns fill out an application. We receive over 3,000 applications per internship cohort, so we really appreciate this Django package! ❤️

We'll pin our django-formtools version to 2.3 until this issue can be fixed. I'll also take a look at the other solutions posted here. I don't know if I'll have time to make them work before our applications open on January 16, 2023, but I'll try. 😅

sagesharp pushed a commit to outreachy/website that referenced this issue Dec 28, 2022
I can't currently update to Django 4.0 because django-formtools 2.4
includes a bug that breaks our usage case of conditional form steps on
the Outreachy initial application:

#537
jazzband/django-formtools#220

I also ran into an odd issue with the home/test_eligibility.py unit
test. The work-around is documented in the code, although Jamey Sharp
and I still have no clue as to what the root cause is.

All the other unit tests use the home/scenarios.py code, and none of
them seem to have issues.

The unit tests are now more verbose, showing the permission errors that
are uncaught, and 404 not found messages. In those cases, we're actually
trying to test that errors are thrown when pages aren't accessed
properly.

I could add more code to catch those permission errors, but
I'm too tired after wrestling with updates. That's future work.

At least we're running a Django version that is receiving security
updates now! Django 3.2 is marked as being a long-term support release,
until early 2024:

https://www.djangoproject.com/download/

However, it will be really important to upgrade to 4.2 in mid-2023, so
that we can be on the next LTS release before 3.2 is deprecated in early
2023.
sagesharp pushed a commit to outreachy/website that referenced this issue Dec 29, 2022
I can't currently update to Django 4.0 because django-formtools 2.4
includes a bug that breaks our usage case of conditional form steps on
the Outreachy initial application:

#537
jazzband/django-formtools#220

I also ran into an odd issue with the home/test_eligibility.py unit
test. The work-around is documented in the code, although Jamey Sharp
and I still have no clue as to what the root cause is.

All the other unit tests use the home/scenarios.py code, and none of
them seem to have issues.

The unit tests are now more verbose, showing the permission errors that
are uncaught, and 404 not found messages. In those cases, we're actually
trying to test that errors are thrown when pages aren't accessed
properly.

I could add more code to catch those permission errors, but
I'm too tired after wrestling with updates. That's future work.

At least we're running a Django version that is receiving security
updates now! Django 3.2 is marked as being a long-term support release,
until early 2024:

https://www.djangoproject.com/download/

However, it will be really important to upgrade to 4.2 in mid-2023, so
that we can be on the next LTS release before 3.2 is deprecated in early
2024.
symroe added a commit to DemocracyClub/EveryElection that referenced this issue Jan 20, 2023
saemideluxe added a commit to basxsoftwareassociation/basxconnect that referenced this issue Jan 31, 2023
claudep added a commit to claudep/django-formtools that referenced this issue May 7, 2023
claudep added a commit to claudep/django-formtools that referenced this issue May 7, 2023
@claudep
Copy link
Contributor

claudep commented May 7, 2023

@schinckel, any opinion about the possible fix in #239 ?

@schinckel
Copy link
Contributor

Yeah, that looks okay to me.

@MrkGrgsn
Copy link

I've just submitted a PR that attempts a more complete fix for this problem. Please have a look and give it a test if you've got time.

VirginiaDooley added a commit to DemocracyClub/EveryElection that referenced this issue Jul 25, 2023
VirginiaDooley added a commit to DemocracyClub/EveryElection that referenced this issue Jul 25, 2023
VirginiaDooley added a commit to DemocracyClub/EveryElection that referenced this issue Jul 25, 2023
sagesharp pushed a commit to outreachy/website that referenced this issue Dec 6, 2023
django-formtools committed a fix in version 2.4.1 for the regression causing our initial application form to not work.

Original bug:

jazzband/django-formtools#220

Fix merged into 2.4.1:

jazzband/django-formtools#239
sagesharp pushed a commit to outreachy/website that referenced this issue Dec 6, 2023
django-formtools committed a fix in version 2.4.1 for the regression causing our initial application form to not work.

Original bug:

jazzband/django-formtools#220

Fix merged into 2.4.1:

jazzband/django-formtools#239
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet