Skip to content

Conversation

@K4liber
Copy link

@K4liber K4liber commented Jan 19, 2025

Introducing a new contract that checks whether the dependency graph of modules (or packages) stick to an acyclic dependencies principle (ADP).
It indicates that modules (or packages) dependencies form a directed acyclic graph (DAG).

@K4liber K4liber mentioned this pull request Jan 19, 2025
Copy link
Owner

@seddonym seddonym left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking an look at this!

I've left a few comments. Overall, the direction of travel really would be to implement most of this in Rust, in the Grimp library - and then just have a thin wrapper around it in Import Linter. But there's a bit to do before we get there.

My current feeling is it might make more sense to release this as a separate package for the time being, but I could be talked around!

Interested to know your thoughts.

@K4liber K4liber marked this pull request as draft January 28, 2025 18:29
@K4liber K4liber changed the title Tree contract AcyclicContract May 31, 2025
@K4liber K4liber force-pushed the issue/221_tree_contract branch from 721ad3c to eaa6987 Compare June 1, 2025 18:18
@K4liber K4liber force-pushed the issue/221_tree_contract branch from eaa6987 to 45289b0 Compare June 1, 2025 18:40
@K4liber K4liber marked this pull request as ready for review June 19, 2025 07:22
@K4liber

This comment was marked as resolved.

@seddonym
Copy link
Owner

Thanks for sharing! I will take a look (though it might be a week or two before I get to it).

That's cool about PyCon Greece (and of course you can talk about Import Linter). Do please share a recording of the talk once it's available, I'd love to watch.

@K4liber
Copy link
Author

K4liber commented Jun 20, 2025

Thanks, no rush :) I will share the presentation if it gets recorded.

@K4liber K4liber force-pushed the issue/221_tree_contract branch from 434fbf1 to e304ad1 Compare September 30, 2025 16:35
continue

if not graph.find_matching_modules(expression=package):
msg = f"Package '{package}' does not exist in the import graph."
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to think about external packages as they will be nodes in the graph, but they won't have any descendants. E.g. if include_external_packages = true, django will be there but we won't have enough information to check for acyclic dependencies within it.

I think the correct check would be also to check the module isn't squashed: https://grimp.readthedocs.io/en/stable/usage.html#ImportGraph.is_module_squashed

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I added a static method _is_internal_module.

@seddonym
Copy link
Owner

seddonym commented Oct 6, 2025

Thanks for latest updates! Tests are failing though...I'll wait until they pass before trying this on Django again.

Now you've rebased off master you can make use of improved tooling for working locally, e.g. just format, just test, just lint. Hope that makes things easier! https://github.com/seddonym/import-linter/blob/main/CONTRIBUTING.rst#development

@K4liber
Copy link
Author

K4liber commented Oct 10, 2025

Thanks for latest updates! Tests are failing though...I'll wait until they pass before trying this on Django again.

Now you've rebased off master you can make use of improved tooling for working locally, e.g. just format, just test, just lint. Hope that makes things easier! https://github.com/seddonym/import-linter/blob/main/CONTRIBUTING.rst#development

Thanks, just simplifies the contribution process. It was not a good idea to discard the tests during the development since a core functionality broke after removing artificial package dependencies. I reintroduced it and the tests pass now, but we kind of made a step back and need to think about grouping the cycles to show the shortest (probably using {module: cycles_including_the_module} mapping and finding the shortest for each module).

@seddonym
Copy link
Owner

need to think about grouping the cycles to show the shortest (probably using {module: cycles_including_the_module} mapping and finding the shortest for each module).

The main thing I think we need to focus on is defining how, precisely, this is going to behave. So don't worry too much about implementing everything (e.g. it would be fine to mock out the response from Grimp). The key is to understand Grimp's API with respect to cycle detection, and then how we present it in Import Linter to end users.

@seddonym
Copy link
Owner

I've been doing some more thinking about this feature - just noting down some findings.

Weighted edges

I think it is useful to think of the 'squashed' version of the graph (i.e. the graph showing the dependencies between subpackages of a single module) as a weighted graph, where the edge weights are the number of imports. For example, the django.db example mentioned earlier would look like this:

image

This is much more helpful information - we can conclude, for example, that django.db.models has a stronger dependency on django.db.backends than the other way around.

Minimal Weighted Feedback Arc Set

We can then find the Minimal Weighted Feedback Arc Set: this represents the minimum number of imports we'd need to
remove from the graph in order to make it acyclic.

This is provided in the igraph library: https://igraph.org/python/api/0.9.6/igraph._igraph.GraphBase.html#feedback_arc_set

The good news is that although the problem is NP-hard, which means for larger graphs it can take an excessive amount of time, the fact that we are running it on a squashed graph seems to mean in practice it is a quick calculation.

I have tried running this on a few cyclic graphs and it seems to pick out sensible results quickly.

So, rather than reporting to the user which cycles exist (which could be a large number even in the case of smallish cyclic graphs like django.db) perhaps it would be more useful to report the imports that would need to be removed to make the graph acyclic. My hope is that that may align with newly-added problematic imports. That certainly seems to be the case sometimes.

How to render failures

We could report this something like this:

No cycles are allowed in django.db.

It could be made acyclic by removing a total of 8 imports:

- django.db.models.fields.related -> django.db.backends.utils (l. 11)
- django.db.models.functions.mixins -> django.db.backends.oracle.functions (l. 42)
- django.db.models.indexes -> django.db.backends.utils (l. 3)
- django.db.models.lookups -> django.db.backends.base.operations (l. 6)
- django.db.models.options -> django.db.backends.utils (l. 173)
- django.db.utils -> django.db.backends (l. 117)

Or, relative style (inconsistent with other contracts, but more concise):

No cycles are allowed in django.db.

It could be made acyclic by removing a total of 8 imports:

- .models.fields.related -> .backends.utils (l. 11)
- .models.functions.mixins -> .backends.oracle.functions (l. 42)
- .models.indexes -> .backends.utils (l. 3)
- .models.lookups -> .backends.base.operations (l. 6)
- .models.options -> .backends.utils (l. 173)
- .utils -> .backends (l. 117)

Or we could present it as a summary per-subpackage.

No cycles are allowed in django.db.

It could be made acyclic by removing a total of 8 imports:

- django.db.backends -> django.db.models (5 imports)
- django.db.backends -> django.db.utils (1 imports)
- django.db.utils -> django.db.models (2 imports)

Wrapping up

So, my current thinking is that we want to get to a place where there is an API in Grimp for the minimum weighted arc feedback set. I'll have a think about what that API should look like.

Then Import Linter will drill down the levels to whatever depth is configured in the contract, and call that API and report on the cycles in one of these three ways.

There is further help we could offer users who are trying to understand what to do about an acyclic graph, such as visualizations. I think this should be out of scope for Import Linter - instead we could implement it within Impulse, a currently tiny package which I also maintain. Perhaps Import Linter could even recommend the use of the tool in its error message.

What are your thoughts? Does this feel like a good direction?

@K4liber
Copy link
Author

K4liber commented Oct 16, 2025

I've been doing some more thinking about this feature - just noting down some findings.

Weighted edges

I think it is useful to think of the 'squashed' version of the graph (i.e. the graph showing the dependencies between subpackages of a single module) as a weighted graph, where the edge weights are the number of imports. For example, the django.db example mentioned earlier would look like this:

image

This is much more helpful information - we can conclude, for example, that django.db.models has a stronger dependency on django.db.backends than the other way around.

Minimal Weighted Feedback Arc Set

We can then find the Minimal Weighted Feedback Arc Set: this represents the minimum number of imports we'd need to

remove from the graph in order to make it acyclic.

This is provided in the igraph library: https://igraph.org/python/api/0.9.6/igraph._igraph.GraphBase.html#feedback_arc_set

The good news is that although the problem is NP-hard, which means for larger graphs it can take an excessive amount of time, the fact that we are running it on a squashed graph seems to mean in practice it is a quick calculation.

I have tried running this on a few cyclic graphs and it seems to pick out sensible results quickly.

So, rather than reporting to the user which cycles exist (which could be a large number even in the case of smallish cyclic graphs like django.db) perhaps it would be more useful to report the imports that would need to be removed to make the graph acyclic. My hope is that that may align with newly-added problematic imports. That certainly seems to be the case sometimes.

How to render failures

We could report this something like this:


No cycles are allowed in django.db.



It could be made acyclic by removing a total of 8 imports:



- django.db.models.fields.related -> django.db.backends.utils (l. 11)

- django.db.models.functions.mixins -> django.db.backends.oracle.functions (l. 42)

- django.db.models.indexes -> django.db.backends.utils (l. 3)

- django.db.models.lookups -> django.db.backends.base.operations (l. 6)

- django.db.models.options -> django.db.backends.utils (l. 173)

- django.db.utils -> django.db.backends (l. 117)

Or, relative style (inconsistent with other contracts, but more concise):


No cycles are allowed in django.db.



It could be made acyclic by removing a total of 8 imports:



- .models.fields.related -> .backends.utils (l. 11)

- .models.functions.mixins -> .backends.oracle.functions (l. 42)

- .models.indexes -> .backends.utils (l. 3)

- .models.lookups -> .backends.base.operations (l. 6)

- .models.options -> .backends.utils (l. 173)

- .utils -> .backends (l. 117)

Or we could present it as a summary per-subpackage.


No cycles are allowed in django.db.



It could be made acyclic by removing a total of 8 imports:



- django.db.backends -> django.db.models (5 imports)

- django.db.backends -> django.db.utils (1 imports)

- django.db.utils -> django.db.models (2 imports)

Wrapping up

So, my current thinking is that we want to get to a place where there is an API in Grimp for the minimum weighted arc feedback set. I'll have a think about what that API should look like.

Then Import Linter will drill down the levels to whatever depth is configured in the contract, and call that API and report on the cycles in one of these three ways.

There is further help we could offer users who are trying to understand what to do about an acyclic graph, such as visualizations. I think this should be out of scope for Import Linter - instead we could implement it within Impulse, a currently tiny package which I also maintain. Perhaps Import Linter could even recommend the use of the tool in its error message.

What are your thoughts? Does this feel like a good direction?

I really like it, rendering the issue together with a solution. That sounds really helpful for users. Let me review it more carefully this weekend after I am back from vacations.

@seddonym
Copy link
Owner

I've made a start on documenting an API in Grimp here.

@K4liber
Copy link
Author

K4liber commented Oct 18, 2025

I've made a start on documenting an API in Grimp here.

  1. The whole idea about presenting cycle breakers is at least one level cooler than presenting raw cycles. I will continue a review of the idea in Grimp PR.

  2. Seems like this PR can go back to Draft and more probably be removed soon than merged. Lets keep it as a potential inspiration for some part of the new implementation, right?

  3. There is the IntegerField and value property of a Field introduced in this PR. Maybe I should create a separate PR with that, what do you think?

  4. I just sent you an e-mail about my presentation "Consistent importing" where I mention Import Linter.

@seddonym
Copy link
Owner

The whole idea about presenting cycle breakers is at least one level cooler than presenting raw cycles. I will continue a review of the idea in Grimp PR.

Great! One thing to note is it doesn't support the cycles between parents and children we identified as a different type of cycle. Possibly that could be a different method in Grimp.

Seems like this PR can go back to Draft and more probably be removed soon than merged.

Whatever you prefer. Once we've finalized the Grimp API, we could adapt this PR or start a new one.

There is the IntegerField and value property of a Field introduced in this PR. Maybe I should create a separate PR with that, what do you think?

Yes sounds good!

I just sent you an e-mail about my presentation "Consistent importing" where I mention Import Linter.

Received! Thanks.

@K4liber K4liber marked this pull request as draft October 19, 2025 11:20
@K4liber
Copy link
Author

K4liber commented Oct 19, 2025

The whole idea about presenting cycle breakers is at least one level cooler than presenting raw cycles. I will continue a review of the idea in Grimp PR.

Great! One thing to note is it doesn't support the cycles between parents and children we identified as a different type of cycle. Possibly that could be a different method in Grimp.

Seems like this PR can go back to Draft and more probably be removed soon than merged.

Whatever you prefer. Once we've finalized the Grimp API, we could adapt this PR or start a new one.

There is the IntegerField and value property of a Field introduced in this PR. Maybe I should create a separate PR with that, what do you think?

Yes sounds good!

I just sent you an e-mail about my presentation "Consistent importing" where I mention Import Linter.

Received! Thanks.

  1. I created PR with Parsed field logic: Parsed field logic introduced #298
  2. I wonder how nominate_cycle_breakers should be utilized in the acyclic contract implementation. Should we present cycle breakers for all descendant of the package (subpackages) or just for the package itself? Maybe another flag consider_subpackages could be introduced.

@seddonym
Copy link
Owner

I wonder how nominate_cycle_breakers should be utilized in the acyclic contract implementation.

This is what I meant by the depth argument. It would default to drilling down to all sub packages, but we could stop it at a certain depth.

@seddonym
Copy link
Owner

seddonym commented Oct 20, 2025

I am going to concentrate on implementing find_cycle_breakers within Grimp, so if you want to look into providing the scaffolding to interact with that in an Import Linter PR, go ahead! You could just stub out some fake responses from Grimp for the time being - now you know the API it should be fairly straightforward.

@seddonym
Copy link
Owner

Also, I have been thinking about the other kind of cycle - the one that can happen between parents and children, and which find_cycle_breakers doesn't report on. IMO this is sufficiently different to warrant a separate contract type. Thought: maybe we should have an acyclic contract type which is for pure, direct cycles like that, and acyclic_packages for the one that uses find_cycle_breakers.

I'd like to concentrate on the find_cycle_breakers one for now but just letting you know my thoughts about the general direction.

@seddonym
Copy link
Owner

FYI, I think I have a working concept here now: python-grimp/grimp#253

Next steps are to add some more thorough tests, and possibly see if there is a way to avoid the igraph dependency - but I might be happy to live with that dependency temporarily, since it is a killer feature IMO!

@K4liber
Copy link
Author

K4liber commented Oct 24, 2025

FYI, I think I have a working concept here now: seddonym/grimp#253

Next steps are to add some more thorough tests, and possibly see if there is a way to avoid the igraph dependency - but I might be happy to live with that dependency temporarily, since it is a killer feature IMO!

Thanks for sharing. I will take a closer look on the new grimp API soon. I think it makes more sense to abandon this PR and start from a scratch based on the new grimp API. I will:

  1. take an IntegerField from this PR and create another PR
  2. try to create another repo with a custom contract to keep the current checkpoint of the work done in this PR, just in case

EDIT:
I just realized I cannot share the custom contract in a clear way since it depends on a grimp functionality that is not released (and probably will not be released soon).

@seddonym
Copy link
Owner

I'm pleased to say that I have now released the latest Grimp, which gives us nominate_cycle_breakers.

I should have time to integrate this into the acyclic contract type, unless you would like to make a start on it? Let me know what you would prefer.

@K4liber
Copy link
Author

K4liber commented Oct 29, 2025

I'm pleased to say that I have now released the latest Grimp, which gives us nominate_cycle_breakers.

I should have time to integrate this into the acyclic contract type, unless you would like to make a start on it? Let me know what you would prefer.

That's really cool the functionality is already in Grimp.

Thanks for updating me with a status and possibility to contribute. However, I think you should know better how to implement the remaining part from the import-linter side. I am glad that we had the conversations inside this PR, I have learn new things. Probably my direct coding contribution regarding the acyclic contract ends here, it does not matter too much that it was not merged after all :) Please mark me as an optional reviewer if you would like a second opinion of what you are going to implement.

I will try to update my presentation on PyCon Wroclaw with things you mentioned. Hopefully, some new people will start to use import-linter (and the acyclic contract as soon as it will be finished) and improve the quality of their code bases.

@seddonym
Copy link
Owner

seddonym commented Nov 3, 2025

Thanks for updating me with a status and possibility to contribute. However, I think you should know better how to implement the remaining part from the import-linter side.

Sounds good. I'm aiming to get this done this week, I'll keep you posted.

I'm closing this PR now, but thanks for all your help pushing the feature forward and helping clarify the thinking around it, it's just as important as submitting code.

@seddonym seddonym closed this Nov 3, 2025
@seddonym
Copy link
Owner

Acyclic Siblings contracts are now available in the latest Import Linter. I'm very excited about this feature and have already started using it. Thanks @K4liber for making this happen!

https://import-linter.readthedocs.io/en/stable/contract_types.html#acyclic-siblings

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants