diff --git a/.all-contributorsrc b/.all-contributorsrc
index 201eb17157..37373bfb46 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -234,6 +234,33 @@
"contributions": [
"conference"
]
+ },
+ {
+ "login": "drnlm",
+ "name": "Neil Muller",
+ "avatar_url": "https://avatars.githubusercontent.com/u/642056?v=4",
+ "profile": "https://github.com/drnlm",
+ "contributions": [
+ "conference"
+ ]
+ },
+ {
+ "login": "mrswats",
+ "name": "Ferran Jovell",
+ "avatar_url": "https://avatars.githubusercontent.com/u/3410779?v=4",
+ "profile": "https://github.com/mrswats",
+ "contributions": [
+ "conference"
+ ]
+ },
+ {
+ "login": "agriyakhetarpal",
+ "name": "Agriya Khetarpal",
+ "avatar_url": "https://avatars.githubusercontent.com/u/74401230?v=4",
+ "profile": "https://github.com/agriyakhetarpal",
+ "contributions": [
+ "conference"
+ ]
}
],
"repoType": "github"
diff --git a/.github/workflows/canonical-source-update.yml b/.github/workflows/canonical-source-update.yml
index 6ae3d71a80..68f8a0a138 100644
--- a/.github/workflows/canonical-source-update.yml
+++ b/.github/workflows/canonical-source-update.yml
@@ -117,9 +117,10 @@ jobs:
run: |
cp _data/conferences.yml /tmp/conferences_before.yml
cp _data/archive.yml /tmp/archive_before.yml 2>/dev/null || true
+ cp _data/legacy.yml /tmp/legacy_before.yml 2>/dev/null || true
- name: Setup Pixi
- uses: prefix-dev/setup-pixi@v0.10.0
+ uses: prefix-dev/setup-pixi@v0.10.1
- name: Run merge scripts
id: merge
@@ -150,6 +151,15 @@ jobs:
# Capture which source was processed for commit message
echo "source_label=$SOURCE" >> $GITHUB_OUTPUT
+ - name: Verify no conference data loss
+ run: |
+ # An automated merge must never delete or rename existing conferences.
+ # This blocks e.g. two distinct conferences being fuzzy-matched into one
+ # (PyCon Africa vs PyCon South Africa) before anything is committed.
+ pixi run python ./utils/check_data_loss.py \
+ --before /tmp/conferences_before.yml /tmp/archive_before.yml /tmp/legacy_before.yml \
+ --after _data/conferences.yml _data/archive.yml _data/legacy.yml
+
- name: Check for changes
id: check_changes
run: |
diff --git a/.github/workflows/check-conference-update.yml b/.github/workflows/check-conference-update.yml
index 303805b156..9db3888c77 100644
--- a/.github/workflows/check-conference-update.yml
+++ b/.github/workflows/check-conference-update.yml
@@ -913,7 +913,7 @@ jobs:
run: echo "GITHUB_ACTOR=${{ github.repository_owner }}" >> "$GITHUB_ENV"
- name: Run Claude Code Analysis
- uses: anthropics/claude-code-action@v1
+ uses: anthropics/claude-code-action@v1.0.191
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_bots: 'github-actions[bot]'
@@ -953,7 +953,7 @@ jobs:
- name: Setup Pixi
if: steps.check_changes.outputs.changed == 'true'
- uses: prefix-dev/setup-pixi@v0.10.0
+ uses: prefix-dev/setup-pixi@v0.10.1
- name: Sort conferences
if: steps.check_changes.outputs.changed == 'true'
@@ -1178,7 +1178,7 @@ jobs:
run: echo "GITHUB_ACTOR=${{ github.repository_owner }}" >> "$GITHUB_ENV"
- name: Run Claude Code Analysis
- uses: anthropics/claude-code-action@v1
+ uses: anthropics/claude-code-action@v1.0.191
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_bots: 'github-actions[bot]'
@@ -1216,7 +1216,7 @@ jobs:
- name: Setup Pixi
if: steps.check_changes.outputs.changed == 'true'
- uses: prefix-dev/setup-pixi@v0.10.0
+ uses: prefix-dev/setup-pixi@v0.10.1
- name: Sort conferences
if: steps.check_changes.outputs.changed == 'true'
diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml
index 334ad88075..c905bee74a 100644
--- a/.github/workflows/ci-core.yml
+++ b/.github/workflows/ci-core.yml
@@ -25,7 +25,7 @@ jobs:
uses: actions/checkout@v7
- name: π Setup Pixi
- uses: prefix-dev/setup-pixi@v0.10.0
+ uses: prefix-dev/setup-pixi@v0.10.1
- name: β
Validate conference data structure
run: |
@@ -56,7 +56,7 @@ jobs:
uses: actions/checkout@v7
- name: π Setup Pixi
- uses: prefix-dev/setup-pixi@v0.10.0
+ uses: prefix-dev/setup-pixi@v0.10.1
- name: π§ͺ Run Python tests
run: |
@@ -77,7 +77,7 @@ jobs:
uses: actions/checkout@v7
- name: π Setup Pixi
- uses: prefix-dev/setup-pixi@v0.10.0
+ uses: prefix-dev/setup-pixi@v0.10.1
- name: π Run pre-commit hooks
run: |
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 70eea9e00b..3e133c89df 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -24,8 +24,8 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Initialize CodeQL
- uses: github/codeql-action/init@v4
+ uses: github/codeql-action/init@v4.37.6
with:
languages: python
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v4
+ uses: github/codeql-action/analyze@v4.37.6
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index 5fa13d1eda..e90aae25ac 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -74,7 +74,7 @@ jobs:
uses: actions/checkout@v7
- name: π’ Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version: '20'
cache: 'npm'
@@ -168,7 +168,7 @@ jobs:
uses: actions/checkout@v7
- name: π’ Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version: '20'
cache: 'npm'
@@ -253,7 +253,7 @@ jobs:
uses: actions/checkout@v7
- name: π’ Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version: '20'
cache: 'npm'
@@ -307,7 +307,7 @@ jobs:
uses: actions/checkout@v7
- name: π’ Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version: '20'
diff --git a/.github/workflows/enrich-tba-conferences.yml b/.github/workflows/enrich-tba-conferences.yml
index e14c1ecaaf..4725c5e135 100644
--- a/.github/workflows/enrich-tba-conferences.yml
+++ b/.github/workflows/enrich-tba-conferences.yml
@@ -105,7 +105,7 @@ jobs:
cp _data/conferences.yml /tmp/conferences_before.yml
- name: Setup Pixi
- uses: prefix-dev/setup-pixi@v0.10.0
+ uses: prefix-dev/setup-pixi@v0.10.1
- name: Install lynx for text extraction
run: sudo apt-get install -qq -y lynx > /dev/null 2>&1 || true
diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml
index 5c0d633735..b5dfedd0b1 100644
--- a/.github/workflows/frontend-tests.yml
+++ b/.github/workflows/frontend-tests.yml
@@ -43,7 +43,7 @@ jobs:
uses: actions/checkout@v7
- name: π’ Setup Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
@@ -160,7 +160,7 @@ jobs:
uses: actions/checkout@v7
- name: π’ Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version: '20'
cache: 'npm'
@@ -185,7 +185,7 @@ jobs:
uses: actions/checkout@v7
- name: π’ Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version: '20'
cache: 'npm'
diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
index 16dbac1afa..593821d10d 100644
--- a/.github/workflows/labeler.yml
+++ b/.github/workflows/labeler.yml
@@ -9,4 +9,4 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- - uses: actions/labeler@v6
+ - uses: actions/labeler@v7
diff --git a/.github/workflows/pr-sort.yml b/.github/workflows/pr-sort.yml
index d094be6372..266f1b2989 100644
--- a/.github/workflows/pr-sort.yml
+++ b/.github/workflows/pr-sort.yml
@@ -38,7 +38,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Pixi
- uses: prefix-dev/setup-pixi@v0.10.0
+ uses: prefix-dev/setup-pixi@v0.10.1
- name: Set variables
id: vars
diff --git a/.github/workflows/recurring-sort.yml b/.github/workflows/recurring-sort.yml
index 5aa55d7ba8..22a7ce76e4 100644
--- a/.github/workflows/recurring-sort.yml
+++ b/.github/workflows/recurring-sort.yml
@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Pixi
- uses: prefix-dev/setup-pixi@v0.10.0
+ uses: prefix-dev/setup-pixi@v0.10.1
- name: Run links command
run: pixi run sort
diff --git a/.github/workflows/sync-changedetection.yaml b/.github/workflows/sync-changedetection.yaml
index b6912e8265..1e93ddac5c 100644
--- a/.github/workflows/sync-changedetection.yaml
+++ b/.github/workflows/sync-changedetection.yaml
@@ -32,7 +32,7 @@ jobs:
fetch-depth: 0
- name: Set up Python
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7
with:
python-version: '3.12'
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ff2a10b3b4..306a1422d4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -209,6 +209,9 @@ Example:
 jungmir π |
+  Neil Muller π |
+  Ferran Jovell π |
+  Agriya Khetarpal π |
diff --git a/README.md b/README.md
index e33ea476ce..e57764ec4b 100644
--- a/README.md
+++ b/README.md
@@ -266,6 +266,9 @@ If you'd like to add support for a new language, please open an issue.
 jungmir π |
+  Neil Muller π |
+  Ferran Jovell π |
+  Agriya Khetarpal π |
diff --git a/_data/archive.yml b/_data/archive.yml
index ec509fa9b2..fcc348b2b9 100644
--- a/_data/archive.yml
+++ b/_data/archive.yml
@@ -1,5 +1,81 @@
---
+- conference: Python Norte
+ year: 2026
+ link: https://pythonnorte.org/
+ cfp: TBA
+ timezone: America/Sao_Paulo
+ place: Ananindeua, Brazil
+ start: 2026-07-03
+ end: 2026-07-05
+ sub: PY
+ location:
+ - title: Python Norte 2026
+ latitude: -1.374035
+ longitude: -48.4016623
+
+- conference: Web Summer Camp
+ year: 2026
+ link: https://websummercamp.com/
+ cfp_link: https://sessionize.com/web-summer-camp-2026/
+ cfp: '2026-02-28 23:59:59'
+ place: Opatija, Croatia
+ start: 2026-07-02
+ end: 2026-07-04
+ sub: WEB
+ location:
+ - title: Web Summer Camp 2026
+ latitude: 45.3349238
+ longitude: 14.3067834
+
+- conference: PyCon Singapore
+ year: 2026
+ link: http://pycon.sg/
+ cfp: TBA
+ place: Singapore, Singapore
+ start: 2026-06-19
+ end: 2026-06-21
+ sponsor: http://pycon.sg/index.html#sponsors
+ sub: PY
+ location:
+ - title: PyCon Singapore 2026
+ latitude: 1.2899175
+ longitude: 103.8519072
+
+- conference: GeoPython
+ year: 2026
+ link: https://2026.geopython.net/
+ cfp_link: https://submit.geopython.net/geopython-2026/cfp
+ cfp: '2026-02-15 23:59:00'
+ cfp_ext: '2026-03-31 23:59:00'
+ tutorial_deadline: '2026-03-31 23:59:00'
+ place: Basel, Switzerland
+ start: 2026-06-08
+ end: 2026-06-10
+ sponsor: https://2026.geopython.net/app/sponsors
+ finaid: https://2026.geopython.net/app/finaid
+ sub: GEO
+ location:
+ - title: GeoPython 2026
+ latitude: 47.3615882
+ longitude: 7.354445
+
+- conference: PyData London
+ year: 2026
+ link: https://pydata.org/london2026
+ cfp_link: https://pydata.org/london2026/cfp#submit
+ cfp: '2026-02-16 23:59:00'
+ timezone: Europe/London
+ place: London, UK
+ start: 2026-06-05
+ end: 2026-06-07
+ sponsor: https://pydata.org/london2026/sponsor
+ sub: DATA
+ location:
+ - title: PyData London 2025
+ latitude: 51.5074456
+ longitude: -0.1277653
+
- conference: PyCon Italy
alt_name: PyCon Italia
year: 2026
@@ -10,7 +86,6 @@
start: 2026-05-27
end: 2026-05-30
sponsor: https://2026.pycon.it/sponsor/
- finaid: https://2026.pycon.it/en
mastodon: https://social.python.it/@pycon
sub: PY
location:
@@ -49,6 +124,35 @@
latitude: 52.2319581
longitude: 21.0067249
+- conference: PyGrunn
+ year: 2026
+ link: https://pygrunn.org/
+ cfp_link: https://forms.gle/5qAYUczPptmGVCY58
+ cfp: TBA
+ place: Groningen, Netherlands
+ start: 2026-05-08
+ end: 2026-05-08
+ sub: PY
+ location:
+ - title: PyGrunn 2026
+ latitude: 53.2190652
+ longitude: 6.5680077
+
+- conference: Python Sul
+ alt_name: Python Sul (Brazil)
+ year: 2026
+ link: https://sul.python.org.br/
+ cfp: TBA
+ timezone: America/Sao_Paulo
+ place: Londrina, Brazil
+ start: 2026-05-01
+ end: 2026-05-03
+ sub: PY
+ location:
+ - title: Python Sul 2026
+ latitude: -23.4761271
+ longitude: -51.1179013
+
- conference: North Bay Python
year: 2026
link: https://northbaypython.org/
@@ -126,7 +230,6 @@
start: 2026-04-14
end: 2026-04-17
sponsor: https://2026.pycon.de/sponsoring/
- finaid: https://2026.pycon.de/
sub: PY,DATA
location:
- title: PyCon Germany & PyData Conference 2026
@@ -262,6 +365,19 @@
latitude: -22.611456150000002
longitude: 17.05860104184979
+- conference: Pytorch Day
+ year: 2026
+ link: https://events.linuxfoundation.org/pytorch-day-india/
+ cfp: TBA
+ place: Bengaluru, India
+ start: 2026-02-07
+ end: 2026-02-07
+ sub: DATA,DAY
+ location:
+ - title: Pytorch Day 2026
+ latitude: 12.9767936
+ longitude: 77.590082
+
- conference: FOSDEM
year: 2026
link: https://fosdem.org/2026/
diff --git a/_data/conferences.yml b/_data/conferences.yml
index d9eb385056..79a78caaf2 100644
--- a/_data/conferences.yml
+++ b/_data/conferences.yml
@@ -1,5 +1,19 @@
---
+- conference: PyCon Lithuania
+ year: 2027
+ link: https://pycon.lt/2027/
+ cfp_link: https://pycon.lt/2027/cfp
+ cfp: '2027-01-13 23:59:00'
+ place: Vilnius, Lithuania
+ start: 2027-04-28
+ end: 2027-04-30
+ sub: PY
+ location:
+ - title: PyCon Lithuania 2027
+ latitude: 54.6870458
+ longitude: 25.2829111
+
- conference: PyCon Wroclaw
year: 2026
link: https://www.pyconwroclaw.com/
@@ -18,6 +32,50 @@
latitude: 51.1089776
longitude: 17.0326689
+- conference: PyCon Namibia
+ year: 2027
+ link: https://na.pycon.org/
+ cfp: '2026-09-21 01:59:00'
+ timezone: Africa/Windhoek
+ place: Windhoek, Namibia
+ start: 2027-02-19
+ end: 2027-02-25
+ mastodon: https://hachyderm.io/@PyConNA
+ bluesky: https://bsky.app/profile/pyconna.bsky.social
+ sub: PY
+ location:
+ - title: PyCon Namibia 2027
+ latitude: -22.5597
+ longitude: 17.0832
+
+- conference: Wagtail Space
+ year: 2026
+ link: https://wagtail.org/wagtail-space-2026/
+ cfp_link: https://pretalx.com/wagtail-space-2026/cfp
+ cfp: '2026-09-11 23:59:00'
+ timezone: UTC
+ place: Online
+ start: 2026-11-18
+ end: 2026-11-20
+ sub: WEB
+
+- conference: Python Pizza Istanbul
+ alt_name: Istanbul Python Pizza
+ year: 2026
+ link: https://istanbul.python.pizza/
+ cfp_link: https://istanbul.python.pizza/cfp/
+ cfp: '2026-08-25 23:59:00'
+ place: Istanbul, Turkey
+ start: 2026-11-14
+ end: 2026-11-14
+ sponsor: https://istanbul.python.pizza/sponsors/
+ twitter: pythonpizzaist
+ sub: PY
+ location:
+ - title: Python Pizza Istanbul 2026
+ latitude: 41.1088
+ longitude: 29.0176
+
- conference: PyCon Netherlands
alt_name: PyCon NL
year: 2026
@@ -52,10 +110,45 @@
latitude: 50.0478
longitude: 12.5604627
+- conference: PyHEP.dev
+ alt_name: PyHEP
+ year: 2026
+ link: https://indico.nikhef.nl/event/7873/
+ cfp: '2026-08-14 23:59:00'
+ timezone: Europe/Amsterdam
+ place: Amsterdam, Netherlands
+ start: 2026-09-07
+ end: 2026-09-09
+ sub: SCIPY
+ location:
+ - title: PyHEP.dev 2026
+ latitude: 52.4111289
+ longitude: 4.877231604293446
+
+- conference: PyCon South Africa
+ alt_name: PyCon ZA
+ year: 2026
+ link: https://za.pycon.org/
+ cfp_link: https://za.pycon.org/pages/speaking/how_to_apply/
+ cfp: '2026-08-14 23:59:00'
+ tutorial_deadline: '2026-08-14 23:59:00'
+ timezone: Africa/Johannesburg
+ place: Rondebosch, South Africa
+ start: 2026-10-14
+ end: 2026-10-18
+ twitter: PyConZA
+ mastodon: https://fosstodon.org/@PyConZA
+ sub: PY
+ location:
+ - title: PyCon South Africa 2026
+ latitude: -33.959834
+ longitude: 18.473872
+
- conference: PyData Global
year: 2026
link: https://pydata.org/global2026/
cfp: '2026-08-03 23:59:00'
+ cfp_ext: '2026-08-24 23:59:00'
workshop_deadline: '2026-08-03 23:59:00'
tutorial_deadline: '2026-08-03 23:59:00'
place: Online
@@ -105,6 +198,7 @@
link: https://2026.pycon.ie/
cfp_link: https://sessionize.com/pycon-ireland-2026/
cfp: '2026-07-31 23:59:00'
+ cfp_ext: '2026-08-30 23:59:00'
place: Dublin, Ireland
start: 2026-10-17
end: 2026-10-17
@@ -133,7 +227,6 @@
- conference: PyCon Hong Kong
year: 2026
link: https://www.pycon.hk/
- cfp_link: https://www.pycon.hk/
cfp: '2026-06-20 23:59:59'
timezone: Asia/Hong_Kong
place: Hong Kong, China
@@ -148,6 +241,7 @@
longitude: 114.1628131
- conference: Python Nordeste
+ alt_name: PyNE
year: 2026
link: https://pythonnordeste.org/
cfp: '2026-06-19 23:59:00'
@@ -242,6 +336,22 @@
latitude: 5.5571096
longitude: -0.2012376
+- conference: PyCon Taiwan
+ alt_name: PyCon TW
+ year: 2026
+ link: https://tw.pycon.org/2026/
+ cfp_link: https://tw.pycon.org/2026/en-us/speaking/cfp
+ cfp: '2026-06-01 23:59:59'
+ place: Taipei, Taiwan
+ start: 2026-10-17
+ end: 2026-10-18
+ sponsor: https://tw.pycon.org/2026/en-us/sponsor
+ sub: PY
+ location:
+ - title: PyCon Taiwan 2026
+ latitude: 25.033
+ longitude: 121.5654
+
- conference: PyCon Togo
year: 2026
link: https://pycontg.pytogo.org/
@@ -266,6 +376,7 @@
place: Hiroshima, Japan
start: 2026-08-21
end: 2026-08-22
+ sponsor: https://2026.pycon.jp/en/sponsors
sub: PY
location:
- title: PyCon Japan 2026
@@ -323,6 +434,24 @@
latitude: -27.5953778
longitude: -48.5480367
+- conference: PyCon Spain
+ year: 2026
+ link: https://2026.es.pycon.org/en/
+ cfp_link: https://pretalx.com/pycones-2026/
+ cfp: '2026-05-17 23:59:00'
+ timezone: Europe/Madrid
+ place: Barcelona, Spain
+ start: 2026-11-06
+ end: 2026-11-08
+ sponsor: https://2026.es.pycon.org/en/sponsors/#about
+ finaid: https://2026.es.pycon.org/en/becas
+ bluesky: https://bsky.app/profile/es.pycon.org
+ sub: PY
+ location:
+ - title: PyCon Spain 2026
+ latitude: 41.3866666
+ longitude: 2.1636166
+
- conference: PyCon Russia
year: 2026
link: https://www.pycon.ru/
@@ -348,7 +477,6 @@
place: Athens, Greece
start: 2026-10-12
end: 2026-10-13
- finaid: https://pycon.gr/
youtube: https://www.youtube.com/@pycongreece
sub: PY
location:
@@ -375,22 +503,6 @@
latitude: 37.5580558199932
longitude: 126.998311394625
-- conference: PyCon Spain
- year: 2026
- link: https://2026.es.pycon.org/en/
- cfp: '2026-05-03 23:59:00'
- timezone: Europe/Madrid
- place: Barcelona, Spain
- start: 2026-11-06
- end: 2026-11-08
- sponsor: https://2026.es.pycon.org/es/sponsors/
- bluesky: https://bsky.app/profile/es.pycon.org
- sub: PY
- location:
- - title: PyCon Spain 2026
- latitude: 41.3828939
- longitude: 2.1774322
-
- conference: PyCon Latin America
alt_name: PyCon LatAm
year: 2026
@@ -557,35 +669,6 @@
latitude: 41.8755616
longitude: -87.6244212
-- conference: Web Summer Camp
- year: 2026
- link: https://websummercamp.com/
- cfp_link: https://sessionize.com/web-summer-camp-2026/
- cfp: '2026-02-28 23:59:59'
- place: Opatija, Croatia
- start: 2026-07-02
- end: 2026-07-04
- sub: WEB
- location:
- - title: Web Summer Camp 2026
- latitude: 45.3349238
- longitude: 14.3067834
-
-- conference: PyCon Estonia
- alt_name: PyCon EE
- year: 2026
- link: https://pycon.ee/
- cfp_link: https://docs.google.com/forms/d/e/1FAIpQLSewbFKG6BEsGXR9ilgsW1wqfmzRR0Pf9XDO5WXTFhrcgrrN3g/viewform
- cfp: '2026-02-28 23:59:00'
- place: Tallinn, Estonia
- start: 2026-10-08
- end: 2026-10-09
- sub: PY
- location:
- - title: PyCon Estonia 2026
- latitude: 59.4372155
- longitude: 24.7453688
-
- conference: SciPy US
alt_name: SciPy
year: 2026
@@ -607,40 +690,6 @@
latitude: 44.9772995
longitude: -93.2654692
-- conference: PyData London
- year: 2026
- link: https://pydata.org/london2026
- cfp_link: https://pydata.org/london2026/cfp#submit
- cfp: '2026-02-16 23:59:00'
- timezone: Europe/London
- place: London, UK
- start: 2026-06-05
- end: 2026-06-07
- sponsor: https://pydata.org/london2026/sponsor
- sub: DATA
- location:
- - title: PyData London 2025
- latitude: 51.5074456
- longitude: -0.1277653
-
-- conference: GeoPython
- year: 2026
- link: https://2026.geopython.net/
- cfp_link: https://submit.geopython.net/geopython-2026/cfp
- cfp: '2026-02-15 23:59:00'
- cfp_ext: '2026-03-31 23:59:00'
- tutorial_deadline: '2026-03-31 23:59:00'
- place: Basel, Switzerland
- start: 2026-06-08
- end: 2026-06-10
- sponsor: https://2026.geopython.net/app/sponsors
- finaid: https://2026.geopython.net/app/finaid
- sub: GEO
- location:
- - title: GeoPython 2026
- latitude: 47.3615882
- longitude: 7.354445
-
- conference: EuroPython
year: 2026
link: https://ep2026.europython.eu/
@@ -694,6 +743,24 @@
latitude: 50.061389
longitude: 19.938333
+- conference: PyCon Sweden
+ year: 2026
+ link: https://pycon.se/
+ cfp_link: https://pretalx.com/pycon-sweden-2026/
+ cfp: '2025-08-23 23:59:00'
+ timezone: Europe/Stockholm
+ place: Stockholm, Sweden
+ start: 2026-11-19
+ end: 2026-11-20
+ sponsor: https://pycon.se/#sponsorship
+ mastodon: https://unsplash.com/@micaelwidell?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText
+ youtube: https://www.youtube.com/c/PyConSweden
+ sub: PY
+ location:
+ - title: PyCon Sweden 2026
+ latitude: 59.3251172
+ longitude: 18.0710935
+
- conference: PyCon Austria
year: 2027
link: https://pycon.at/
@@ -713,34 +780,37 @@
alt_name: PyCon Germany
year: 2027
link: https://2027.pycon.de/
- cfp_link: https://2027.pycon.de/
cfp: TBA
timezone: Europe/Berlin
place: Heidelberg, Germany
start: 2027-04-20
end: 2027-04-22
- sponsor: https://2027.pycon.de/
+ sponsor: https://2027.pycon.de/sponsoring/
+ mastodon: https://fosstodon.org/@pyconde
sub: PY,DATA
location:
- title: PyCon Germany & PyData Conference 2027
latitude: 49.4093582
longitude: 8.694724
-- conference: PyCon Namibia
- year: 2027
- link: https://na.pycon.org/
- cfp: TBA
- timezone: Africa/Johannesburg
- place: Windhoek, Namibia
- start: 2027-02-19
- end: 2027-02-25
- mastodon: https://hachyderm.io/@PyConNA
- bluesky: https://bsky.app/profile/pyconna.bsky.social
- sub: PY
+- conference: SciPy India
+ year: 2026
+ link: https://scipy.in/2026/
+ cfp: '2026-10-19 23:59:00'
+ cfp_link: https://cfp.scipy.in/scipy-india-2026/cfp
+ timezone: Asia/Kolkata
+ place: Chennai, India
+ start: 2026-12-19
+ end: 2026-12-20
+ sponsor: https://scipy.in/2026/sponsor.html
+ mastodon: https://fosstodon.org/@scipyindia
+ bluesky: https://bsky.app/profile/scipy.in
+ youtube: https://www.youtube.com/@scipy-india
+ sub: SCIPY
location:
- - title: PyCon Namibia 2027
- latitude: -22.5597
- longitude: 17.0832
+ - title: SciPy India 2026
+ latitude: 12.9917014
+ longitude: 80.2295271
- conference: Python E-Commerce Forum
year: 2026
@@ -755,22 +825,6 @@
latitude: 55.9533456
longitude: -3.1883749
-- conference: PyCon Sweden
- year: 2026
- link: https://pycon.se/
- cfp: TBA
- place: Stockholm, Sweden
- start: 2026-11-19
- end: 2026-11-20
- sponsor: https://pycon.se/#sponsorship
- mastodon: https://unsplash.com/@micaelwidell?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText
- youtube: https://www.youtube.com/c/PyConSweden
- sub: PY
- location:
- - title: PyCon Sweden 2026
- latitude: 59.3251172
- longitude: 18.0710935
-
- conference: XtremePython
year: 2026
link: https://xtremepython.dev/
@@ -780,6 +834,20 @@
end: 2026-11-17
sub: PY
+- conference: PyCon Tanzania
+ alt_name: Python Tanzania Conference
+ year: 2026
+ link: https://pycon.or.tz/
+ cfp: TBA
+ place: Dar Es Salaam, Tanzania
+ start: 2026-11-14
+ end: 2026-11-16
+ sub: PY
+ location:
+ - title: PyCon Tanzania 2026
+ latitude: -6.8160837
+ longitude: 39.2803583
+
- conference: FastAPI Conference
year: 2026
link: https://fastapiconf.com/
@@ -793,6 +861,36 @@
latitude: 52.3730796
longitude: 4.8924534
+- conference: PyCon PanamΓ‘
+ year: 2026
+ link: https://pycon.pa/2026/
+ cfp: TBA
+ place: Panama City, PanamΓ‘
+ start: 2026-10-22
+ end: 2026-10-23
+ sub: PY
+ location:
+ - title: PyCon PanamΓ‘ 2026
+ latitude: 8.9714493
+ longitude: -79.5341802
+
+- conference: PyCon Estonia
+ alt_name: PyCon EE
+ year: 2026
+ link: https://pycon.ee/
+ cfp_link: https://docs.google.com/forms/d/e/1FAIpQLSewbFKG6BEsGXR9ilgsW1wqfmzRR0Pf9XDO5WXTFhrcgrrN3g/viewform
+ cfp: Cancelled
+ cfp_ext: '2026-02-28 23:59:00'
+ place: Tallinn, Estonia
+ start: 2026-10-08
+ end: 2026-10-09
+ sponsor: https://pycon.ee/#sponsors
+ sub: PY
+ location:
+ - title: PyCon Estonia 2026
+ latitude: 59.4372155
+ longitude: 24.7453688
+
- conference: EARL
year: 2026
link: https://earl-conference.com/
@@ -813,6 +911,7 @@
year: 2026
link: https://barcamps.eu/pythoncamp-ruegen-2026/
cfp: TBA
+ timezone: Europe/Berlin
place: RΓΌgen, Germany
start: 2026-09-26
end: 2026-09-27
@@ -841,6 +940,7 @@
year: 2026
link: https://barcamps.eu/python-barcamp-leipzig-2026/
cfp: TBA
+ timezone: Europe/Berlin
place: Leipzig, Germany
start: 2026-08-01
end: 2026-08-02
@@ -865,31 +965,3 @@
- title: PyData PyCon Armenia 2026
latitude: 40.1777112
longitude: 44.5126233
-
-- conference: Python Norte
- year: 2026
- link: https://pythonnorte.org/
- cfp: TBA
- timezone: America/Sao_Paulo
- place: Ananindeua, Brazil
- start: 2026-07-03
- end: 2026-07-05
- sub: PY
- location:
- - title: Python Norte 2026
- latitude: -1.374035
- longitude: -48.4016623
-
-- conference: PyCon Singapore
- year: 2026
- link: http://pycon.sg/
- cfp: TBA
- place: Singapore, Singapore
- start: 2026-06-19
- end: 2026-06-21
- sponsor: http://pycon.sg/index.html#sponsors
- sub: PY
- location:
- - title: PyCon Singapore 2026
- latitude: 1.2899175
- longitude: 103.8519072
diff --git a/package-lock.json b/package-lock.json
index 0c5b1c15e6..1d4b54c68f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1393,10 +1393,11 @@
"dev": true
},
"node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
diff --git a/tests/test_fuzzy_match.py b/tests/test_fuzzy_match.py
index f7b11ea2fd..4216a4512c 100644
--- a/tests/test_fuzzy_match.py
+++ b/tests/test_fuzzy_match.py
@@ -495,6 +495,115 @@ def test_below_90_percent_no_prompt(self, mock_title_mappings):
assert len(remote) >= 1
+class TestSubsetNameCollision:
+ """Regression tests for distinct conferences whose names score 100.
+
+ token_set_ratio returns 100 when one name's tokens are a subset of the
+ other's (e.g. "PyCon Africa" vs "PyCon South Africa"). Such pairs must
+ never be auto-merged as "exact" matches - that previously renamed
+ PyCon South Africa to PyCon Africa and deleted the real PyCon Africa
+ entry (commit d76e737).
+ """
+
+ @staticmethod
+ def _africa_yaml(include_africa: bool) -> pd.DataFrame:
+ rows = {
+ "conference": ["PyCon South Africa"],
+ "year": [2026],
+ "cfp": ["2026-06-01 23:59:00"],
+ "link": ["https://za.pycon.org/"],
+ "place": ["Rondebosch, South Africa"],
+ "start": ["2026-10-14"],
+ "end": ["2026-10-18"],
+ }
+ df = pd.DataFrame(rows)
+ if include_africa:
+ africa = pd.DataFrame(
+ {
+ "conference": ["PyCon Africa"],
+ "year": [2026],
+ "cfp": ["2026-04-16 23:59:00"],
+ "link": ["https://africa.pycon.org/"],
+ "place": ["Kampala, Uganda"],
+ "start": ["2026-10-07"],
+ "end": ["2026-10-11"],
+ },
+ )
+ df = pd.concat([df, africa], ignore_index=True)
+ return df
+
+ @staticmethod
+ def _africa_remote() -> pd.DataFrame:
+ return pd.DataFrame(
+ {
+ "conference": ["PyCon Africa"],
+ "year": [2026],
+ "cfp": [""],
+ "link": ["https://africa.pycon.org/"],
+ "place": ["Kampala, Uganda"],
+ "start": ["2026-10-07"],
+ "end": ["2026-10-11"],
+ "sponsor": ["https://africa.pycon.org/2026/sponsor-us/"],
+ },
+ )
+
+ def test_subset_name_is_not_an_exact_match(self, mock_title_mappings):
+ """A token-subset name pair must not auto-merge without confirmation.
+
+ Contract: "PyCon South Africa" vs "PyCon Africa" scores 100 via
+ token_set_ratio, but the names are not identical, so it must go
+ through the fuzzy confirmation path (which defaults to "no" in CI).
+ """
+ df_yml = self._africa_yaml(include_africa=False)
+ df_remote = self._africa_remote()
+
+ # Non-interactive / user rejects: conferences stay separate
+ with patch("builtins.input", return_value="n"):
+ result, _remote, _report = fuzzy_match(df_yml, df_remote)
+
+ conf_list = result["conference"].tolist()
+ assert "PyCon South Africa" in conf_list, f"PyCon South Africa was renamed/merged away: {conf_list}"
+
+ za_row = result[result["conference"] == "PyCon South Africa"].iloc[0]
+ assert za_row["link"] == "https://za.pycon.org/"
+ assert za_row["start"] == "2026-10-14"
+
+ def test_both_africa_conferences_survive_merge(self, mock_title_mappings):
+ """Reproduce the d76e737 incident: both conferences must survive.
+
+ YAML has PyCon Africa (Kampala) and PyCon South Africa (Rondebosch);
+ the remote CSV has only PyCon Africa. The remote row belongs to its
+ identically-named YAML entry - PyCon South Africa must be left
+ completely alone, without prompting.
+ """
+ df_yml = self._africa_yaml(include_africa=True)
+ df_remote = self._africa_remote()
+
+ with patch(
+ "builtins.input",
+ side_effect=AssertionError("Should not prompt: remote row belongs to identical YAML entry"),
+ ):
+ result, _remote, _report = fuzzy_match(df_yml, df_remote)
+
+ conf_list = result["conference"].tolist()
+ assert "PyCon Africa" in conf_list, f"PyCon Africa was lost: {conf_list}"
+ assert "PyCon South Africa" in conf_list, f"PyCon South Africa was lost: {conf_list}"
+
+ za_row = result[result["conference"] == "PyCon South Africa"].iloc[0]
+ assert za_row["link"] == "https://za.pycon.org/"
+ assert za_row["place"] == "Rondebosch, South Africa"
+ assert za_row["start"] == "2026-10-14"
+ assert pd.isna(za_row.get("sponsor")) or za_row.get("sponsor") in (
+ "",
+ None,
+ ), "PyCon South Africa must not inherit PyCon Africa's sponsor link"
+
+ africa_row = result[result["conference"] == "PyCon Africa"].iloc[0]
+ assert africa_row["place"] == "Kampala, Uganda"
+ assert africa_row["start"] == "2026-10-07"
+ assert africa_row["sponsor"] == "https://africa.pycon.org/2026/sponsor-us/"
+
+
class TestDataPreservation:
"""Test that original data is preserved through fuzzy matching."""
diff --git a/tests/test_redundant_links.py b/tests/test_redundant_links.py
new file mode 100644
index 0000000000..d47d7af1b8
--- /dev/null
+++ b/tests/test_redundant_links.py
@@ -0,0 +1,122 @@
+"""Tests for dropping sub-page link fields that just repeat the homepage.
+
+Upstream sources sometimes fill every URL column with the conference
+homepage (e.g. python-organizers' Proposal URL). A cfp_link/sponsor/finaid
+identical to the main link carries no information and must be dropped
+during sanitation - but a different path, subdomain, query, or #anchor is a
+different pointer and must survive.
+"""
+
+import sys
+from pathlib import Path
+
+sys.path.append(str(Path(__file__).parent.parent / "utils"))
+
+from tidy_conf.links import drop_redundant_link_fields
+from tidy_conf.links import normalize_url_pointer
+
+
+class TestNormalizeUrlPointer:
+ """Test URL normalization for same-pointer comparison."""
+
+ def test_scheme_ignored(self):
+ assert normalize_url_pointer("http://pycon.de/") == normalize_url_pointer("https://pycon.de/")
+
+ def test_www_prefix_ignored(self):
+ assert normalize_url_pointer("https://www.pycon.de/") == normalize_url_pointer("https://pycon.de/")
+
+ def test_trailing_slash_ignored(self):
+ assert normalize_url_pointer("https://2027.pycon.de/") == normalize_url_pointer("https://2027.pycon.de")
+
+ def test_fragment_is_different_pointer(self):
+ assert normalize_url_pointer("http://pycon.sg/#sponsors") != normalize_url_pointer("http://pycon.sg/")
+
+ def test_path_is_different_pointer(self):
+ assert normalize_url_pointer("https://pycon.de/sponsoring/") != normalize_url_pointer("https://pycon.de/")
+
+ def test_subdomain_is_different_pointer(self):
+ assert normalize_url_pointer("https://cfp.pycon.de/") != normalize_url_pointer("https://pycon.de/")
+
+ def test_query_is_different_pointer(self):
+ assert normalize_url_pointer("https://pycon.de/?page=cfp") != normalize_url_pointer("https://pycon.de/")
+
+
+class TestDropRedundantLinkFields:
+ """Test removal of sub-page links identical to the main link."""
+
+ def test_cfp_link_same_as_link_dropped(self):
+ """Reproduces the PyCon DE 2027 case: cfp_link is just the homepage."""
+ data = [
+ {
+ "conference": "PyCon DE & PyData",
+ "year": 2027,
+ "link": "https://2027.pycon.de/",
+ "cfp_link": "https://2027.pycon.de/",
+ "cfp": "TBA",
+ },
+ ]
+ result = drop_redundant_link_fields(data)
+ assert "cfp_link" not in result[0]
+ assert result[0]["link"] == "https://2027.pycon.de/"
+
+ def test_all_redundant_sub_fields_dropped(self):
+ data = [
+ {
+ "conference": "Cheeky Conf",
+ "year": 2026,
+ "link": "https://cheeky.conf/",
+ "cfp_link": "https://cheeky.conf",
+ "sponsor": "http://www.cheeky.conf/",
+ "finaid": "https://cheeky.conf/",
+ },
+ ]
+ result = drop_redundant_link_fields(data)
+ assert "cfp_link" not in result[0]
+ assert "sponsor" not in result[0]
+ assert "finaid" not in result[0]
+
+ def test_anchor_on_homepage_kept(self):
+ """A #anchor is a different pointer (e.g. PyCon SG's sponsor link)."""
+ data = [
+ {
+ "conference": "PyCon Singapore",
+ "year": 2026,
+ "link": "http://pycon.sg/",
+ "sponsor": "http://pycon.sg/index.html#sponsors",
+ },
+ ]
+ result = drop_redundant_link_fields(data)
+ assert result[0]["sponsor"] == "http://pycon.sg/index.html#sponsors"
+
+ def test_sub_page_and_subdomain_kept(self):
+ data = [
+ {
+ "conference": "PyCon Africa",
+ "year": 2026,
+ "link": "https://africa.pycon.org/",
+ "cfp_link": "https://africa.pycon.org/2026/talks/proposals/",
+ "sponsor": "https://africa.pycon.org/2026/sponsor-us/",
+ "finaid": "https://africa.pycon.org/2026/opportunity-grants/",
+ },
+ {
+ "conference": "Sub Conf",
+ "year": 2026,
+ "link": "https://sub.conf/",
+ "cfp_link": "https://cfp.sub.conf/",
+ },
+ ]
+ result = drop_redundant_link_fields(data)
+ assert result[0]["cfp_link"] == "https://africa.pycon.org/2026/talks/proposals/"
+ assert result[0]["sponsor"] == "https://africa.pycon.org/2026/sponsor-us/"
+ assert result[0]["finaid"] == "https://africa.pycon.org/2026/opportunity-grants/"
+ assert result[1]["cfp_link"] == "https://cfp.sub.conf/"
+
+ def test_entry_without_link_untouched(self):
+ data = [{"conference": "No Link Conf", "year": 2026, "cfp_link": "https://example.com/"}]
+ result = drop_redundant_link_fields(data)
+ assert result[0]["cfp_link"] == "https://example.com/"
+
+ def test_entry_without_sub_fields_untouched(self):
+ data = [{"conference": "Plain Conf", "year": 2026, "link": "https://plain.conf/"}]
+ result = drop_redundant_link_fields(data)
+ assert result[0] == {"conference": "Plain Conf", "year": 2026, "link": "https://plain.conf/"}
diff --git a/tests/test_youtube_extraction.py b/tests/test_youtube_extraction.py
index 87da528d1f..e4eee280bd 100644
--- a/tests/test_youtube_extraction.py
+++ b/tests/test_youtube_extraction.py
@@ -75,6 +75,29 @@ def test_generic_mastodon_still_works(self, mock_links):
assert "mastodon" in result
assert "youtube" not in result
+ @patch("enrich_tba.get_all_links")
+ def test_google_maps_viewport_not_mastodon(self, mock_links):
+ """Google Maps /@lat,lng,zoom viewport URLs must not be detected as mastodon.
+
+ Regression test: a venue map link like
+ https://www.google.com/maps/place/Transformatorhuis/@52.386807,4.8698442,17z
+ contains "/@" but is not a Mastodon profile.
+ """
+ mock_links.return_value = [
+ "https://www.google.com/maps/place/Transformatorhuis/@52.386807,4.8698442,17z",
+ ]
+ result = extract_links_from_url("https://fastapiconf.com")
+ assert "mastodon" not in result
+
+ @patch("enrich_tba.get_all_links")
+ def test_user_at_instance_profile_still_works(self, mock_links):
+ """Full /@user@instance profile paths are still detected as mastodon."""
+ mock_links.return_value = [
+ "https://social.example.org/@pyconf@mastodon.social",
+ ]
+ result = extract_links_from_url("https://pyconf.org")
+ assert result.get("mastodon") == "https://social.example.org/@pyconf@mastodon.social"
+
@patch("enrich_tba.get_all_links")
def test_youtube_first_seen_wins(self, mock_links):
"""Only the first YouTube link is kept."""
diff --git a/utils/cfp_candidates.py b/utils/cfp_candidates.py
new file mode 100644
index 0000000000..b5f160b3a1
--- /dev/null
+++ b/utils/cfp_candidates.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""Emit conference series that likely need a new edition checked.
+
+A candidate is a series with no entry in _data/conferences.yml (nothing
+upcoming or TBA) whose latest archived edition is recent enough that the
+series is plausibly still alive. Output is JSON on stdout, newest first.
+
+Intended for the cfp-scout routine: run once at session start, then work
+from the JSON instead of re-reading the data files.
+"""
+
+import argparse
+import json
+from datetime import datetime
+from datetime import timezone
+from pathlib import Path
+
+import yaml
+
+CARRY_FIELDS = ("link", "place", "sub", "alt_name", "cfp_link")
+
+
+def load(path: Path) -> list[dict]:
+ if not path.exists():
+ return []
+ with path.open() as fh:
+ return yaml.safe_load(fh) or []
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--lookback",
+ type=int,
+ default=2,
+ help="Only consider series whose latest edition is within this many years (default: 2)",
+ )
+ parser.add_argument("--base", type=Path, default=Path(), help="Repository root")
+ args = parser.parse_args()
+
+ current_year = datetime.now(tz=timezone.utc).year
+ cutoff = current_year - args.lookback
+
+ current_names = {c["conference"] for c in load(args.base / "_data/conferences.yml")}
+
+ latest: dict[str, dict] = {}
+ for conf in load(args.base / "_data/archive.yml"):
+ name = conf.get("conference")
+ year = conf.get("year") or 0
+ if not name or name in current_names:
+ continue
+ if year > latest.get(name, {}).get("latest_year", 0):
+ entry = {"conference": name, "latest_year": year}
+ entry.update({k: conf[k] for k in CARRY_FIELDS if conf.get(k)})
+ latest[name] = entry
+
+ candidates = sorted(
+ (info for info in latest.values() if info["latest_year"] >= cutoff),
+ key=lambda x: (-x["latest_year"], x["conference"]),
+ )
+ print(json.dumps({"generated": f"{current_year}", "count": len(candidates), "candidates": candidates}, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/utils/check_data_loss.py b/utils/check_data_loss.py
new file mode 100644
index 0000000000..4f316088e0
--- /dev/null
+++ b/utils/check_data_loss.py
@@ -0,0 +1,90 @@
+"""Guard against silent conference loss during automated data merges.
+
+Compares snapshots of the conference data files taken before a merge with the
+files after the merge. Every (conference, year) pair that existed before must
+still exist afterwards - in any of the given files, since conferences may
+legitimately move between conferences.yml, archive.yml and legacy.yml.
+
+Exits non-zero if any conference disappeared, so CI can block the commit.
+A legitimate rename (via titles.yml mappings) will also trip this check;
+that is intentional - renames of existing entries should be reviewed by a
+human, not auto-committed.
+"""
+
+import argparse
+import sys
+from pathlib import Path
+
+import yaml
+
+
+def load_conference_keys(paths: list[str]) -> set[tuple[str, int | str]]:
+ """Collect (conference, year) pairs from a list of YAML data files.
+
+ Missing files are skipped, so the same invocation works whether or not
+ optional files like legacy.yml exist.
+ """
+ keys = set()
+ for path in paths:
+ file = Path(path)
+ if not file.exists():
+ continue
+ with file.open(encoding="utf-8") as f:
+ data = yaml.safe_load(f)
+ if not data:
+ continue
+ for entry in data:
+ if not isinstance(entry, dict):
+ continue
+ conference = entry.get("conference")
+ year = entry.get("year")
+ if conference:
+ keys.add((str(conference).strip(), year))
+ return keys
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Fail if conferences disappeared from the data files.",
+ )
+ parser.add_argument(
+ "--before",
+ nargs="+",
+ required=True,
+ help="Data files snapshotted before the merge",
+ )
+ parser.add_argument(
+ "--after",
+ nargs="+",
+ required=True,
+ help="Data files after the merge",
+ )
+ args = parser.parse_args()
+
+ before = load_conference_keys(args.before)
+ after = load_conference_keys(args.after)
+ missing = before - after
+
+ if missing:
+ print(
+ f"ERROR: {len(missing)} conference(s) disappeared during the merge:",
+ file=sys.stderr,
+ )
+ for conference, year in sorted(missing, key=str):
+ print(f" - {conference} ({year})", file=sys.stderr)
+ print(
+ "\nAn automated merge must never delete or rename existing conferences.\n"
+ "This usually means two distinct conferences were fuzzy-matched into one\n"
+ "(e.g. 'PyCon Africa' vs 'PyCon South Africa'). Add the pair to\n"
+ "utils/tidy_conf/data/rejections.yml, or if the rename is intentional,\n"
+ "apply it manually.",
+ file=sys.stderr,
+ )
+ return 1
+
+ print(f"OK: all {len(before)} conference entries survived the merge.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/utils/enrich_tba.py b/utils/enrich_tba.py
index bed4ec4bcf..93a24a6e10 100644
--- a/utils/enrich_tba.py
+++ b/utils/enrich_tba.py
@@ -272,6 +272,11 @@ def _domain_matches(domain: str, hosts: tuple[str, ...]) -> bool:
return any(domain == h or domain.endswith(f".{h}") for h in hosts)
+# A Mastodon profile URL's path is exactly "/@handle" (or "/@user@instance").
+# Requiring this shape prevents false positives from other "/@" URLs, e.g.
+# Google Maps viewports like "/maps/place/Venue/@52.386807,4.8698442,17z".
+MASTODON_PROFILE_RE = re.compile(r"^/@[A-Za-z0-9_][\w.-]*(?:@[\w.-]+)?/?$")
+
# Known Mastodon instances (common ones in tech/Python community)
MASTODON_INSTANCES = {
"mastodon.social",
@@ -366,8 +371,8 @@ def extract_links_from_url(url: str) -> dict[str, str]:
found["mastodon"] = link
seen_types.add("mastodon")
logger.debug(f" Found mastodon: {link}")
- elif "/@" in parsed_link.path:
- # Generic /@username pattern - likely Mastodon-compatible
+ elif MASTODON_PROFILE_RE.match(parsed_link.path):
+ # Path is exactly /@username - likely Mastodon-compatible
found["mastodon"] = link
seen_types.add("mastodon")
logger.debug(f" Found mastodon (generic): {link}")
diff --git a/utils/sort_yaml.py b/utils/sort_yaml.py
index 2eba1ae2ed..c627ee9513 100644
--- a/utils/sort_yaml.py
+++ b/utils/sort_yaml.py
@@ -22,6 +22,7 @@
from tidy_conf.latlon import add_latlon
from tidy_conf.links import check_link_availability
from tidy_conf.links import check_mastodon_migration
+ from tidy_conf.links import drop_redundant_link_fields
from tidy_conf.links import get_cache
from tidy_conf.schema import Conference
from tidy_conf.schema import get_schema
@@ -35,6 +36,7 @@
from .tidy_conf.latlon import add_latlon
from .tidy_conf.links import check_link_availability
from .tidy_conf.links import check_mastodon_migration
+ from .tidy_conf.links import drop_redundant_link_fields
from .tidy_conf.links import get_cache
from .tidy_conf.schema import Conference
from .tidy_conf.schema import get_schema
@@ -289,6 +291,10 @@ def sort_data(base="", prefix="", skip_links=False):
logger.info("π·οΈ Cleaning titles")
data = tidy_titles(data)
+ # Drop sub-page links that just repeat the homepage
+ logger.info("π Dropping redundant link fields")
+ data = drop_redundant_link_fields(data)
+
# Add Sub
logger.info("π’ Adding submission types")
data = auto_add_sub(data)
diff --git a/utils/tidy_conf/data/rejections.yml b/utils/tidy_conf/data/rejections.yml
index d9c6e345a3..4fa46225e0 100644
--- a/utils/tidy_conf/data/rejections.yml
+++ b/utils/tidy_conf/data/rejections.yml
@@ -1,4 +1,3 @@
----
alt_name:
AfroPython Conference:
variations:
@@ -14,6 +13,11 @@ alt_name:
variations:
- PyCon Australia
- PyCon AU
+ PyCon Africa:
+ variations:
+ - PyCon South Africa
+ - PyCon ZA
+ - PyConZA
PyCon Austria:
variations:
- PyCon Australia
@@ -30,6 +34,9 @@ alt_name:
PyCon Latin America:
variations:
- PyCon Latam
+ PyCon South Africa:
+ variations:
+ - PyCon Africa
Python Austria:
variations:
- PyCon Australia
diff --git a/utils/tidy_conf/interactive_merge.py b/utils/tidy_conf/interactive_merge.py
index d511b802d6..c26225249a 100644
--- a/utils/tidy_conf/interactive_merge.py
+++ b/utils/tidy_conf/interactive_merge.py
@@ -55,6 +55,29 @@
}
+def is_identical_name(s1: str, s2: str) -> bool:
+ """Check whether two conference names are genuinely identical.
+
+ A fuzzy score of 100 does NOT imply identity: token_set_ratio returns 100
+ whenever one name's tokens are a subset of the other's (e.g. "PyCon Africa"
+ vs "PyCon South Africa"). Only names that are equal after case and
+ whitespace normalization may be auto-merged without confirmation.
+
+ Parameters
+ ----------
+ s1 : str
+ First conference name to compare
+ s2 : str
+ Second conference name to compare
+
+ Returns
+ -------
+ bool
+ True if the names are identical after normalization
+ """
+ return " ".join(s1.lower().split()) == " ".join(s2.lower().split())
+
+
def is_placeholder_value(value) -> bool:
"""Check if a value is a placeholder (TBA, TBD, None, empty).
@@ -276,6 +299,31 @@ def is_excluded(name1, name2):
"""Check if two conference names are in the combined exclusion list."""
return frozenset([name1, name2]) in all_exclusions
+ def best_match(title_match):
+ """Extract (title, score) from a process.extract result (2- or 3-tuple)."""
+ match_result = title_match[0]
+ if len(match_result) == 3:
+ title, prob, _ = match_result
+ else:
+ title, prob = match_result
+ return title, prob
+
+ # Remote titles already spoken for by a YAML row with the genuinely
+ # identical name. No other YAML row may merge into these - otherwise two
+ # distinct conferences collapse into one row and one of them is lost
+ # (e.g. "PyCon South Africa" must not merge into remote "PyCon Africa"
+ # when a YAML "PyCon Africa" also exists).
+ identical_titles = set()
+ for _, row in df.iterrows():
+ if isinstance(row["title_match"], str) or not row["title_match"]:
+ continue
+ title, _prob = best_match(row["title_match"])
+ if is_identical_name(row["conference"], title):
+ identical_titles.add(title)
+
+ # Remote titles claimed by a YAML row during this run (title -> yaml name)
+ claimed_titles = {}
+
# Process matches and track in report
for i, row in df.iterrows():
if isinstance(row["title_match"], str):
@@ -283,12 +331,7 @@ def is_excluded(name1, name2):
if not row["title_match"]:
continue
- # Handle both 2-tuple and 3-tuple results from process.extract
- match_result = row["title_match"][0]
- if len(match_result) == 3:
- title, prob, _ = match_result
- else:
- title, prob = match_result
+ title, prob = best_match(row["title_match"])
conference_name = row["conference"]
year = row.get("year", 0)
@@ -311,21 +354,45 @@ def is_excluded(name1, name2):
df.at[i, "title_match"] = conference_name # Use original name, not index
record.match_type = "excluded"
record.action = "kept_yaml"
- elif prob >= EXACT_MATCH_THRESHOLD:
- logger.debug(
- f"Exact match: '{conference_name}' -> '{title}' (score: {prob})",
- )
- df.at[i, "title_match"] = title
- record.match_type = "exact"
- record.action = "merged"
+ elif prob >= EXACT_MATCH_THRESHOLD and is_identical_name(conference_name, title):
+ # Only genuinely identical names merge automatically. A score of
+ # 100 alone is NOT sufficient: token_set_ratio scores 100 whenever
+ # one name's tokens are a subset of the other's.
+ if title in claimed_titles and claimed_titles[title] != conference_name:
+ logger.warning(
+ f"Remote '{title}' already claimed by '{claimed_titles[title]}', "
+ f"keeping '{conference_name}' separate",
+ )
+ df.at[i, "title_match"] = conference_name
+ record.match_type = "exact"
+ record.action = "kept_yaml"
+ else:
+ logger.debug(
+ f"Exact match: '{conference_name}' -> '{title}' (score: {prob})",
+ )
+ df.at[i, "title_match"] = title
+ claimed_titles[title] = conference_name
+ record.match_type = "exact"
+ record.action = "merged"
elif prob >= FUZZY_MATCH_THRESHOLD:
+ if title in identical_titles or title in claimed_titles:
+ # Remote row already belongs to its identically-named YAML
+ # entry (or was claimed earlier) - never merge another
+ # conference into it.
+ logger.info(
+ f"Skipping fuzzy match: remote '{title}' already belongs to an "
+ f"identically-named entry, keeping '{conference_name}' separate",
+ )
+ df.at[i, "title_match"] = conference_name
+ record.match_type = "fuzzy"
+ record.action = "kept_yaml"
# Prompt user for fuzzy matches that aren't excluded
- logger.info(
- f"Fuzzy match candidate: '{conference_name}' -> '{title}' (score: {prob})",
- )
- if not query_yes_no(
+ elif not query_yes_no(
f"Do '{row['conference']}' and '{title}' match? (y/n): ",
):
+ logger.info(
+ f"Fuzzy match rejected: '{conference_name}' vs '{title}' (score: {prob})",
+ )
new_rejections[title].append(conference_name)
new_rejections[conference_name].append(title)
df.at[i, "title_match"] = conference_name # Use original name, not index
@@ -334,6 +401,7 @@ def is_excluded(name1, name2):
else:
new_mappings[conference_name].append(title)
df.at[i, "title_match"] = title
+ claimed_titles[title] = conference_name
record.match_type = "fuzzy"
record.action = "merged"
else:
diff --git a/utils/tidy_conf/links.py b/utils/tidy_conf/links.py
index c630d352a2..a3adec7241 100644
--- a/utils/tidy_conf/links.py
+++ b/utils/tidy_conf/links.py
@@ -9,6 +9,71 @@
import requests
from tqdm import tqdm
+# Sub-page link fields that must point somewhere other than the main link.
+# A cfp_link/sponsor/finaid that is just the homepage again carries no
+# information (e.g. "cfp_link: https://2027.pycon.de/" on the PyCon DE entry
+# whose link is https://2027.pycon.de/) - it should be a different page, a
+# subdomain, or at least a #anchor on the homepage.
+REDUNDANT_LINK_FIELDS = ("cfp_link", "sponsor", "finaid")
+
+
+def normalize_url_pointer(url: str) -> tuple:
+ """Normalize a URL for same-pointer comparison.
+
+ Two URLs are the same pointer when they only differ by scheme
+ (http/https), a "www." prefix, host case, or a trailing slash.
+ Anything else - path, subdomain, query, or #fragment - makes them
+ different pointers.
+
+ Parameters
+ ----------
+ url : str
+ URL to normalize
+
+ Returns
+ -------
+ tuple
+ Comparable (host, path, params, query, fragment) tuple
+ """
+ parsed = urlparse(str(url).strip())
+ netloc = parsed.netloc.lower().removeprefix("www.")
+ return (netloc, parsed.path.rstrip("/"), parsed.params, parsed.query, parsed.fragment)
+
+
+def drop_redundant_link_fields(data: list) -> list:
+ """Drop sub-page link fields that are the same pointer as the main link.
+
+ Goes through cfp_link/sponsor/finaid on each conference and
+ removes any that just repeat the conference's main link. This catches
+ upstream sources that fill every URL column with the homepage.
+
+ Parameters
+ ----------
+ data : list
+ List of conference dictionaries
+
+ Returns
+ -------
+ list
+ The same list with redundant link fields removed
+ """
+ for q in data:
+ if not isinstance(q, dict):
+ continue
+ link = q.get("link")
+ if not link:
+ continue
+ base = normalize_url_pointer(link)
+ for field in REDUNDANT_LINK_FIELDS:
+ value = q.get(field)
+ if value and normalize_url_pointer(value) == base:
+ tqdm.write(
+ f"Dropping redundant '{field}' from {q.get('conference')} {q.get('year')}: "
+ f"'{value}' is the same pointer as the main link",
+ )
+ del q[field]
+ return data
+
def get_cache_location():
# Check if the URL is cached