> if the code/project has no users or active developers for 3 years, at what point does it just become cruft?
At the time the hardware cannot possibly support it. So, we are talking about something like 50 years. For more high-level languages the deadline should extend further (i.e. languages like C should, in principle, have shorter shelf life because they have stronger dependency on the hardware they run on). This is how, ideally it should work. I.e. there's no reason any Python code should be dead by now: Python simply hadn't been around long enough for that to become a real, impossible to solve issue. Ironically, C programs have far better shelf life than Python. About an order of magnitude better.
Second deadline is when businesses will stop using project's code if it doesn't have enough shelf life. This is somewhere between 10 and 5 years. Government / military will want the longer shelf life (and usually have enough bargaining power to compel the developers), while the rest of the industry will suck it up and accept the inevitable death of their software. But, at some point, it no longer makes sense to sacrifice the profits just to keep up with the trends. Eg. you can still find businesses that haven't upgraded from CentOS 7 (released in 2014), and are doing OK. A lot of military software uses 90s or even 80s tech. Banking is also a special case here, as well as some embedded systems that can be many decades old eg. railway networks.
I wish that anything below that threshold wasn't commercially viable... but, obviously, a lot of businesses find enough resources to upgrade more frequently. I feel like these businesses make a disservice to the broader community of users depending on the same technology. In some ways, their ability to upgrade more often is also a way to beat the competition. Unfortunately, more often, it's not just the competition that suffers from this, but also businesses in completely unrelated fields.
----
Now, if talking about Python specifically, there's quite a bit of difference based on particular area the code is written for. Web and data science are the areas with the worst shelf life, and require the most effort to simply stand still. These are the areas where projects typically have more dependencies, attract a lot of attention (and so there's more drive to iterate faster on those dependencies).
But, on Python's side, there are plenty of bad things happening regardless of what individual teams can do. For example, when talking about support of software like operating systems, this includes support of a large number of individual (optional) components that need to work together. I.e. the LTS Linux distribution will have a set of packages that are (allegedly) tested to work together, and if you want to have a reliable baseline for your own software, you just have to draw from a particular distribution and you should be fine. PyPI, or more broadly, Python community doesn't have that kind of concept. There's no set of dependencies that can be guaranteed for any length of time at any time. Anaconda tried to be more like that, but they were overpowered by the users' desire to get new things out of bound.
Another problem with how Python versions packages is that it imposes virtually no restrictions on publishing. If a package author wants to upgrade ten major package versions in a day, there's nobody to tell it to stop. Want to publish an older version than the last one published? -- sure, go ahead. And, of course, on top of it, interface breakage in versions where that's not supposed to happen etc. The poor stewardship of PyPI is a self-fulfilling prophecy: package maintainers realize that the versioning is, essentially, broken in Python, and in order to prevent their packages from breaking, they narrow down the range of acceptable dependency versions as much as possible (and often only accept the single version of the dependency they tested with). This, in turn, results in a situation where upgrades are impossible without massive breakage: incremental upgrades become impossible, because no part of the dependency tree can be upgraded independently. On the other hand, if a single dependency in this tree goes stale, the whole tree is stale.
Another factor to this is that dependencies which accept a wider range of transitive dependencies are harder to install using currently available package managers / installers. I.e. because pip / conda will spend a lot more time trying to find a solution for dependency graph with more potential candidates (due to more loose version requirements), developers are prone to try and expedite the process by narrowing down version constrains. This is especially painful in conda world, where the solver can take many hours (or days in pathological cases) to solve such installations. And so developers have to choose between being more friendly to the user (and supporting multiple transitive dependency versions) or being more hostile to the user (by narrowing down supported versions) while speeding up their deployment.
Yet another factor is the design of Python module system that is unaware of module versions. I.e. if only it was possible to specify the version of the imported module at import time, a lot of problems would've gone away. There would've been no need for virtual environments. Subsequently, there'd be more incentive to ensure compatibility of various independent libraries and programs, which would put pressure on package maintainers to dedicate more time to integration testing and slow down the release cycle.