Where does all the effort go? Looking at Python core developer activity

One of the tasks given me by the Python Software Foundation as part of the Developer in Residence job was to look at the state of CPython as an active software development project. What are people working on? Which standard libraries require most work? Who are the active experts behind which libraries? Those were just some of the questions asked by the Foundation. In this post I’m looking into our Git repository history and our Github PR data to find answers.

All statistics below are based on public data gathered from the python/cpython Git repository and its pull requests. To make the data easy to analyze, they were converted into Python objects with a bunch of scripts that are also open source. The data is stored as a shelf which is a persistent dictionary-like object. I used that since it was the simplest possible thing I could do and very flexible. It was easy to control consistency that way, which was important for doing incremental updates: the project we’re analyzing changes every hour!

Downloading Github PR data from scratch using its REST API is a time consuming process due to the rate limits it imposes of clients. It will take you multiple hours to do it. Fortunately, since this is based on the immutable history of the Git repository and historical pull requests, you can speed things up significantly if you download an existing shelve.db file and start from there.

Before we begin

The work here is based on a snapshot of data in time, it deliberately merges some information, skips over other information, and might otherwise be incomplete or inaccurate due to it essentially being preliminary work. Please avoid drawing far reaching conclusions from this post alone.

Who is who?

Even though the entire dataset comes from public sources, e-mail addresses are considered personally identifiable information so I avoid collecting them by using Github usernames instead. This is mostly fine but is a tricky proposition when data from the Git repository needs to be linked as well. Commit authors and co-authors are listed in commit metadata and the commit message using the Authored-by and Co-authored-by headers) using the traditional NAME <email@address> notation.

To link them, I used a handy user search endpoint in Github’s REST API. Again, due to rate limits, I cache the results (including misses) to avoid wasting queries on addresses I already asked for. That file I won’t be sharing though, you’ll have to recreate that from scratch if you want it. Luckily, some e-mail addresses in the repository commits are already cloaked by Github (like 55281+ambv@users.noreply.github.com), making it trivial to retrieve the Github username from that.

However, it turns out that pretty often those e-mail addresses aren’t the same as the primary e-mail address listed for a given account on Github. To circumvent that, I also imported the same information from the private PEP 13 voters database core developers hold for the purpose of electing the Steering Council each year. And finally, I used a little hack: for each unknown e-mail address, I retrieved all Github PRs with commits where it appears as an author, and assumed that the most common creator of those PRs has to be the owner of this e-mail address.

How to best explore this data?

I quickly found that writing custom Python scripts to get every piece of interesting information is somewhat tiresome. With all data in a shelf, it’s easy to put it in a Jupyter notebook and go from there. That’s what a professional data scientist would do, I guess. In fact, if you’re up for it, show me how – it might be interesting.

I myself wished for some good old SQL querying ability instead, so I converted the database to a SQLite file. I didn’t have to do much thanks to Simon Willison’s super-handy sqlite-utils library which – among other features – allows creating new SQLite tables automatically on first data insert. Wonderful! The db.sqlite file is also available for download if you’d like to analyze it yourself.

I personally spent most time analyzing it with Datasette. It allows for a lot of nice point-and-click queries with foreign key support, grouping by arbitrary data ("facets” in Datasette parlance), and exposes a raw SQL querying text box too when you need that. But it gets even better if you install plugins for it:

$ datasette install datasette-vega
$ datasette install datasette-seaborn

With those, Datasette grows the ability to visualize data you’re looking at pretty much for free. Let’s go through a few easy examples first. I run datasette with the following arguments to allow for more lengthy queries:

$ datasette \
  --setting sql_time_limit_ms 300000 \
  --setting facet_time_limit_ms 300000 \
  --setting num_sql_threads 10 \
  db.sqlite

Date ranges

Let’s start with timestamps since this will allow us to understand what timeframe we’re discussing here. First, when you launch Datasette on the SQLite export, go to the changes table and click the merged_at suggested facet, you’ll get:

So right away you see that September 2019 was the most active recorded week in our database in terms of merges. That’s no surprise, it was the week of our annual core sprint, that year happening at Bloomberg in London. To make this look nicer, let’s modify the query a little:

select
  date(merged_at),
  count(*)
from
  changes
where
  merged_at is not null
group by
  date(merged_at)
order by
  count(*) desc
limit
  24;

This generates the following nice graph with the Vega plugin:

We can clearly see that a core sprint generates 2X - 3X the activity as the “next best thing”. It’s tangible evidence those events are worth it. But wait, weren’t we saying that the Python 3.6 core sprint at Facebook in 2016 was the most productive week in the project’s history? Why isn’t it there.

It’s because that predates CPython’s migration to Git. Since my goal is analyzing the modern state of the project, its active committers, pull requests, and so on, using a cut off date of February 10 2017 seemed sensible. And indeed, the oldest change in the database is GH-1 from that date:

At the time of last update to this post, the database ends with GH-28825 (opened on Saturday, October 9 2021).

What are the hot parts of the codebase?

CPython is a huge software project. sloccount by David A. Wheeler counts that it currently consists of over 629,000 significant lines of Python code and over 550,000 significant lines of C code. It’s interesting to know where the developers are making most changes these days. One way is to look at the files table and go from there:

select
  name,
  count(change_id),
  sum(changes)
from
  files
  inner join changes on change_id = changes.id
where
  changes.merged_at is not null
  and changes.opened_at > date('2019-01-01')
  and changes.opened_at < date('2022-01-01')
  and name not like 'Misc/%'
  and name not like 'Doc/%'
  and name not like '%.txt'
  and name not like '%.html'
group by
  name
order by
  count(change_id) desc;

Here’s the Top 50:

# File name Merged PRs Lines changed
1. Python/ceval.c 259 12972
2. Python/pylifecycle.c 222 6046
3. Python/compile.c 194 9053
4. Objects/typeobject.c 182 6484
5. Makefile.pre.in 177 1295
6. Lib/typing.py 166 4461
7. Modules/posixmodule.c 160 8014
8. Objects/unicodeobject.c 156 4907
9. configure.ac 154 1872
10. configure 153 68320
11. Lib/test/test_typing.py 146 4551
12. Modules/_testcapimodule.c 145 4576
13. Lib/test/test_ssl.py 143 4207
14. Lib/test/support/__init__.py 140 3856
15. Python/pystate.c 133 3130
16. setup.py 131 3562
17. Modules/_ssl.c 125 5745
18. Python/sysmodule.c 120 2986
19. Grammar/python.gram 117 3422
20. Lib/test/test_exceptions.py 109 2460
21. Lib/test/test_embed.py 109 3071
22. Python/importlib_external.h 108 318195
23. .github/workflows/build.yml 105 1252
24. Modules/_sqlite/connection.c 103 3034
25. Lib/unittest/mock.py 103 1729
26. Lib/test/test_syntax.py 102 2207
27. Lib/test/_test_multiprocessing.py 100 2333
28. Python/ast.c 97 8308
29. Lib/enum.py 96 5115
30. Objects/dictobject.c 93 2648
31. Lib/test/test_enum.py 92 4679
32. Programs/_testembed.c 91 4607
33. Mac/BuildScript/build-installer.py 89 1391
34. PCbuild/pythoncore.vcxproj 88 258
35. Python/bltinmodule.c 87 1055
36. Lib/test/test_ast.py 86 2412
37. Python/initconfig.c 84 3009
38. Python/import.c 84 2162
39. Objects/object.c 84 1761
40. Modules/main.c 83 4223
41. Include/internal/pycore_pylifecycle.h 83 426
42. Parser/pegen.c 82 1539
43. Parser/parser.c 82 116018
44. PCbuild/python.props 82 285
45. Lib/test/test_os.py 81 1828
46. Python/pythonrun.c 80 2487
47. Python/importlib.h 79 176191
48. Modules/gcmodule.c 79 2066
49. Lib/idlelib/editor.py 79 1677
50. Lib/test/test_logging.py 77 1204

This is already plenty interesting. Who would think the most change happens the deepest inside the interpreter? ceval.c, pylifecycle.c, compile.c, typeobject.c… those are some hairy parts of the codebase. You can also see from the number of changed lines that those are no small changes either.

If you follow the changes one by one, you’ll see that in many cases big changes to a given area stem from open PEPs. For instance, the grammar file along with pegen.c and parser.c are obviously related to PEP 617. If you looked at changes from 2017-2018, you wouldn’t find those files anywhere near the top. That’s why I included a date range in the query.

Who is contributing these days?

Contributing can be many things. In the context of this post, we understand it as authoring patches, commits, or pull requests, commenting on pull requests, reviewing pull requests, and merging pull requests. With the following query we can ask who contributed to the most merged changes:

select
  name,
  count(change_id)
from
  contributors
  inner join changes on change_id = changes.id
where
  changes.merged_at is not null
group by
  name
order by
  count(change_id) desc;

What’s the current top 50 entries?

# Github name Number of merged PRs
1. miss-islington 8259
2. vstinner 3775
3. web-flow 2626
4. serhiy-storchaka 2582
5. pablogsal 1249
6. terryjreedy 1161
7. zooba 959
8. ambv 864
9. rhettinger 814
10. ned-deily 712
11. methane 671
12. Mariatta 650
13. benjaminp 647
14. ZackerySpytz 582
15. blurb-it[bot] 579
16. tiran 489
17. andresdelfino 424
18. berkerpeksag 421
19. gpshead 415
20. 1st1 376
21. csabella 362
22. corona10 354
23. JulienPalard 313
24. erlend-aasland 299
25. pitrou 293
26. asvetlov 269
27. taleinat 254
28. brettcannon 247
29. ncoghlan 237
30. zware 231
31. gvanrossum 226
32. iritkatriel 217
33. vsajip 216
34. matrixise 211
35. zhangyangyu 204
36. tirkarthi 203
37. orsenthil 203
38. ericvsmith 196
39. isidentical 193
40. Fidget-Spinner 192
41. markshannon 188
42. encukou 185
43. shihai1991 169
44. jaraco 157
45. ethanfurman 144
46. lysnikolaou 143
47. ilevkivskyi 137
48. skrah 121
49. aeros 121
50. ammaraskar 119

Clearly, it pays to be a bot (like miss-islington, web-flow, or blurb-it) or or a release manager since this naturally causes you to make a lot of commits. But Victor Stinner and Serhiy Storchaka are neither of these things and still generate amazing amounts of activity. Kudos! In any case, this is no competition but it was still interesting to see who makes all these recent changes.

Who contributes where?

We have a self-reported Experts Index in the Python Developer’s Guide. Many libraries and fields don’t have anyone listed though, so let’s try to find who is contributing where. Especially given the previous file-based activity, it’s interesting to see who works on what. However, the files table contains 18,184 distinct filenames. That’s too much to form decent groups for analytics.

So instead, I wrote a script to identify the top 5 contributors per file. There is a lot of deduplication there and some pruning of irrelevant results but sadly the end result is still 636 categories. Well, it’s a huge project, maybe that should be expected if we want to be detailed. I’m sure we could sensibly bring it down still but I erred on the side of providing more information rather than too little.

The full result is here. As you can see, only 18 categories don’t contain our two giants, Serhiy and Victor. So we can assume they’re looking over the entire project and remove them from the listing to see who else is there. When you do that, the list drops down to 542 categories. I won’t go through the entire set here but let’s just look at two examples. The Experts Index lists R. David Murray as the maintainer of email, let’s see what he’s up to:

$ cat experts_no_giants.txt | grep bitdancer
Lib/argparse.py: rhettinger (41), asottile (11), bitdancer (9), wimglenn (8), encukou (7)
Lib/email: maxking (90), bitdancer (44), warsaw (32), delirious-lettuce (27), ambv (22)
Lib/mailbox.py: ZackerySpytz (3), asvetlov (3), jamesfe (3), webknjaz (3), bitdancer (2), csabella (2)

Makes sense, looks like he is indeed laser-focusing on that area of Python. Let’s look at typing now:

$ cat experts_no_giants.txt | grep -E "/(typing|types.py)"
Lib/types.py: gvanrossum (17), Fidget-Spinner (17), ambv (12), pablogsal (10), ericvsmith (6)
Lib/typing.py: ilevkivskyi (135), Fidget-Spinner (100), gvanrossum (93), ambv (90), uriyyo (58)

Looks like there’s a healthy set of contributors here. Sadly, the top contributor here is Ivan Levkivskyi who is no longer active. There is a number of libraries like this, decimal being another example that comes to mind. In fact, some files are missing contributors entirely save for our two top giants. What are those files? I included them here.

Merging an average PR

What can you expect when you open your average PR? How soon will it be merged? How much review is it going to get? Obviously, the answer in a big project is “it depends”. Averages lie. But I was still curious.

select
  avg(
    julianday(changes.merged_at) - julianday(changes.opened_at)
  )
from
  changes
where
  changes.merged_at is not null;

The answer at the moment is 14.64 days. How about closing the ones we don’t end up merging?

select
  avg(
    julianday(changes.closed_at) - julianday(changes.opened_at)
  )
from
  changes
where
  changes.merged_at is null
  and changes.closed_at is not null;

Here we’re decidedly slower at over 105 days, with the longest one taking over 4 years to close.

But as I said, averages lie. Can we separate the query so that we see how long it takes to merge a PR authored by a core developer versus a PR authored by a community member? Yes, we can. The query looks like this:

select
  avg(
    julianday(changes.merged_at) - julianday(changes.opened_at)
  )
from
  changes
  inner join contributors on changes.id = change_id
where
  changes.merged_at is not null
  and contributors.is_pr_author = true
  and contributors.is_core_dev = true;

We can flip is_core_dev to false to check for non-core developer PRs. The results now show the following: it takes 9.47 days to get an average PR merged if it’s authored by a core developer, versus 19.52 if it isn’t. It’s kind of expected since review of fellow core developer work is often quicker, right? But the truth is even simpler than that. Look at this modified query:

select
  avg(
    julianday(changes.merged_at) - julianday(changes.opened_at)
  )
from
  changes
  inner join contributors on changes.id = change_id
where
  changes.merged_at is not null
  and contributors.is_pr_author = true
  and contributors.is_core_dev = true
  and contributors.did_merge_pr = true;

Yes, when a core developer is motivated to get their change merged, they push for it and in the end often merge their own change. In this case it takes a hair less than 7 days to get a PR merged. Core developer-authored PRs which aren’t merged by their authors take 20.12 days on average to merge, which is pretty close to non-core developer changes.

However, as I already said, averages lie. One thing that annoyed me here is that SQLite doesn’t provide a std dev aggregation. I reached out to Simon Willison and he showed me a Datasette plugin called datasette-statistics that added additional aggregations. Standard deviation wasn’t included so I added it. Now all you need to do is to install the plugin:

$ datasette install datasette-statistics

and you can use statistics_stdev in queries in place of builtin aggregations like avg(), count(), min(), or max().

In our particular case, the standard deviation of the last queries is as follows:

  • core developer authoring and merging their own PR takes on average ~7 days (std dev ±41.96 days);
  • core developer authoring a PR which was merged by somebody else takes on average 20.12 days (std dev ±77.36 days);
  • community member-authored PRs get merged on average after 19.51 days (std dev ±81.74 days).

Well, if we were a company selling code review services, this standard deviation value would be an alarmingly large result. But in our situation which is almost entirely volunteer-driven, the goal of my analysis is to just observe and record data. The large standard deviation reflects the large amount of variation but isn’t necessarily something to worry about. We could do better with more funding but fundamentally our biggest priority is keeping CPython stable. Certain care with integrating changes is required. Erring on the side of caution seems like a wise thing to do.

Next steps

The one missing link here is looking at our issue tracker: bugs.python.org. I decided to leave this data source to a separate investigation since its link with the Git repository and Github PRs is weaker. It’s an interesting dataset on its own though, with close to 50,000 closed issues, and over 7,000 unclosed ones.

One good question that will be answered by looking at it is “which standard libraries require most maintenance?”. Focusing on Git and Github pull requests also necessarily skips over issues where there is no solution in sight. Measuring how often this happens and which parts of Python are most likely to have this kind of problem is where I will be looking next.

Finally, I’m sure we can dig deeper into the dataset we already have. If you have any suggestions on things I could look at, let me know.

#Programming #Python