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
jungmir

πŸ“† + Neil Muller
Neil Muller

πŸ“† + Ferran Jovell
Ferran Jovell

πŸ“† + Agriya Khetarpal
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
jungmir

πŸ“† + Neil Muller
Neil Muller

πŸ“† + Ferran Jovell
Ferran Jovell

πŸ“† + Agriya Khetarpal
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