Compare commits

...

5 Commits

15 changed files with 663 additions and 334 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

6
.github/renovate.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": [
"config:base",
"github>nikdoof/renovate-config:python"
]
}

33
.github/workflows/build-container.yaml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Build Container
"on":
push:
branches:
- main
tags:
- "[0-9]+.[0-9]+.[0-9]+"
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v6
with:
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:latest

30
.github/workflows/lint.yaml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Lint
'on':
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Install dependencies
run: uv sync
- name: Lint with ruff
run: uv run ruff check

33
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,33 @@
---
name: Release
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Build Release
run: uv build
- name: Release
uses: softprops/action-gh-release@v2
with:
name: "Version ${{ github.ref_name }}"
files: |
dist/*

209
.gitignore vendored
View File

@@ -1,4 +1,207 @@
config.ini
.vscode
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
# App specific files
*.ics
*.kml
config.ini

View File

@@ -1,10 +1,10 @@
# Changelog
## 2.3.1 - 2025-08-??
## 2.4.0 - 2025-08-??
- Add support for pushing events to a CalDAV server.
- Add a more detailed description to the events
-
- Add a more detailed description to the events.
- Remove KML support.
## 2.3.0 - 2024-11-30

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Build stage
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
# Result
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY generate_feeds.py ./
RUN mkdir -p /app/config && \
useradd --create-home --shell /bin/bash app && \
chown -R app:app /app
ENV PATH="/app/.venv/bin:$PATH"
USER app
# Set entrypoint with config file option
ENTRYPOINT ["python", "generate_feeds.py", "-c", "/app/config/config.ini"]
CMD ["--help"]

317
README.md
View File

@@ -1,177 +1,264 @@
# Foursquare Feeds
A Python script that will generate iCal (`.ics`) or KML files of your checkins on [Foursquare][4sq]/[Swarm][swarm].
A Python tool that downloads your check-ins from [Foursquare][4sq]/[Swarm][swarm] and converts them into calendar events. You can either generate an iCal (`.ics`) file or sync directly to a CalDAV server.
If you set it up to save the iCal file to a publicly-visible location on a webserver, and run the script regularly, you can subscribe to the feed in your favourite calendar application.
A KML file can be loaded into a mapping application (such as Google Earth or
Maps) to view the checkins on a map.
Foursquare [used to have such feeds][feeds] but they've stopped working for me.
[I wrote a bit about this.][blog]
Perfect for keeping a record of your travels and activities in your preferred calendar application.
[4sq]: https://foursquare.com
[swarm]: https://www.swarmapp.com
[feeds]: https://foursquare.com/feeds/
[blog]: https://www.gyford.com/phil/writing/2019/05/13/foursquare-swarm-ical-feed/
## Features
- **iCal Export**: Generate `.ics` files that can be imported into any calendar application
- **CalDAV Sync**: Upload check-ins directly to CalDAV servers (Google Calendar, iCloud, etc.)
- **Flexible Fetching**: Get recent check-ins or your entire history
- **Rich Event Data**: Includes location details, notes (shouts), and check-in metadata
## Installation
This should work with python 3.12 (and maybe others).
This project requires Python 3.12+ and uses [uv](https://github.com/astral-sh/uv) for dependency management.
### 1. Make a Foursquare app
### 1. Install uv
Go to https://foursquare.com/developers/apps and create a new App.
If you don't have uv installed:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
### 2. Install python requirements
### 2. Set up the project
Either using [uv](https://github.com/astral-sh/uv):
Clone the repository and create a virtual environment:
$ uv sync
```bash
git clone https://github.com/nikdoof/foursquare-feeds.git
cd foursquare-feeds
uv sync
```
or [pip](https://pip.pypa.io/en/stable/):
### 3. Create a Foursquare app
$ pip install -r requirements.txt
1. Go to https://foursquare.com/developers/apps
2. Create a new App
3. Note your Client ID and Client Secret
### 4. Configure the application
### 3. Set up config file
Copy the example configuration file:
Copy `config_example.ini` to `config.ini`.
```bash
cp config_example.ini config.ini
```
Change the `IcsFilepath` and `KmlFilepath` to wherever you want your files to be saved.
Edit `config.ini` with your settings:
To get the `AccessToken` for your Foursquare app, you will have to go through the sometimes laborious procedure in step 4...
- **AccessToken**: Your Foursquare access token (see below)
- **IcsFilepath**: Where to save the `.ics` file (for local export)
- **CalDAV settings**: Your CalDAV server details (for direct sync)
## Getting an Access Token
### 4. Get an access token
You need a Foursquare access token to use this tool. Here are two methods:
There are two ways to do this: (A) The quick way, using a third-party website or (B) the slow way, on the command line. Use (A) unless the website isn't working.
### Method A: Quick Web-based Authentication
#### (A) The quick way
1. Visit https://your-foursquare-oauth-token.glitch.me
2. Follow the Foursquare login link
3. Accept the permissions
4. Copy the access token into your `config.ini`
Go to https://your-foursquare-oauth-token.glitch.me and follow the link to log
in with Foursquare.
*Thanks to [Simon Willison](https://github.com/dogsheep/swarm-to-sqlite/issues/4) for this tool.*
Accept the permissions, and then copy the long code, which is your Access
Token, into your `config.ini`.
### Method B: Manual OAuth Flow
That's it. [Thanks to Simon Willison for that.](https://github.com/dogsheep/swarm-to-sqlite/issues/4)
#### (B) The slow way
On https://foursquare.com/developers/apps, in your app, set the Redirect URI to `http://localhost:8000/`
In a terminal window, open a python shell:
$ python
and, using your app's Client ID and Client Secret in place of `YOUR_CLIENT_ID` and `YOUR_CLIENT_SECRET` enter this:
1. Set your app's Redirect URI to `http://localhost:8000/` in the Foursquare developer console
2. Run the following Python commands:
```python
import foursquare
client = foursquare.Foursquare(client_id='YOUR_CLIENT_ID' client_secret='YOUR_CLIENT_SECRET', redirect_uri='http://localhost:8000')
client.oauth.auth_url()
client = foursquare.Foursquare(
client_id='YOUR_CLIENT_ID',
client_secret='YOUR_CLIENT_SECRET',
redirect_uri='http://localhost:8000'
)
print(client.oauth.auth_url())
```
This will output something like:
'https://foursquare.com/oauth2/authenticate?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2F'
Copy the URL from your terminal *without the surrounding quotes* and paste it into a web browser.
Your browser should redirect to a URL like the one below, with an error about not being able to connect to the server (unless you have a webserver running locally on your machine):
http://localhost:8000/?code=XX_CODE_RETURNED_IN_REDIRECT_XX#_=_
Copy the code represented by `XX_CODE_RETURNED_IN_REDIRECT_XX` (note that there may be an extra `#_=_` on the end which *you should not copy*).
Back in your python shell, with that code, enter this, replacing
`XX_CODE_RETURNED_IN_REDIRECT_XX` with the code you just copied:
3. Visit the printed URL in your browser
4. Copy the code from the redirect URL
5. Get your token:
```python
client.oauth.get_token('XX_CODE_RETURNED_IN_REDIRECT_XX')
client.oauth.get_token('YOUR_CODE_HERE')
```
This will output another long code, which is your Access Token.
## Usage
Enter this in your `config.ini`.
### Generate an iCal file
## Run the script
Generate a `.ics` file:
$ ./generate_feeds.py
This should create an `.ics` file containing up to 250 of your most recent
checkins (see `--all` argument below to get more).
If the file is generated in a location on your website that's publicly-visible, you should be able to subscribe to it from a calendar application. Then run the script periodically to have it update.
Note that the file might contain private checkins or information you don't want to be public. In which case, it's probably best to make the name of any such publicly-readable file very obscure.
To generate a `.kml` file, see the `kind` option below.
### Script options
#### `--all`
By default the script only fetches the most recent 250 checkins. To fetch ALL checkins add the `--all` flag:
Create a `.ics` file with your recent check-ins:
```bash
$ ./generate_feeds.py --all
uv run ./generate_feeds.py
```
Depending on how many checkins you have you might only want to run it with
`--all` the first time and, once that's imported into a calendar application,
subsequently only fetch recent checkins.
### `-k` or `--kind`
By default the script generates an iCal `.ics` file. Or, use this option to
specify an `.ics` file or a `.kml` file:
Get all your check-ins (may take a while):
```bash
$ ./generate_feeds.py -k ics
$ ./generate_feeds.py -k kml
$ ./generate_feeds.py --kind=ics
$ ./generate_feeds.py --kind=kml
uv run ./generate_feeds.py --all
```
#### `-v` or `--verbose`
### Sync to CalDAV
By default the script will only output text if something goes wrong. To get
brief output use `-v` or `--verbose`:
Upload check-ins directly to a CalDAV server:
```bash
$ ./generate_feeds.py -v
Fetched 250 checkins from the API
Generated calendar file ./mycalendar.ics
uv run ./generate_feeds.py --kind caldav
```
If fetching `--all` checkins then increasing the verbosity with another `-v`
will show more info than the above:
Make sure your CalDAV settings are configured in `config.ini`.
## Configuration
The `config.ini` file supports these sections:
### [Foursquare]
- `AccessToken`: Your Foursquare API access token
### [Local]
- `IcsFilepath`: Path where the `.ics` file should be saved
### [CalDAV]
- `url`: Your CalDAV server URL
- `username`: CalDAV username
- `password`: CalDAV password
- `calendar_name`: Name of the calendar to create/use
## Command Line Options
### `--all`
Fetch all check-ins instead of just the recent 250:
```bash
uv run ./generate_feeds.py --all
```
### `--kind` / `-k`
Specify output type (`ics` or `caldav`):
```bash
uv run ./generate_feeds.py --kind ics
uv run ./generate_feeds.py --kind caldav
```
### `--verbose` / `-v`
Enable verbose output:
```bash
uv run ./generate_feeds.py -v # Basic info
uv run ./generate_feeds.py -vv # Detailed progress (with --all)
```
### `--config` / `-c`
Specify a custom config file path:
```bash
uv run ./generate_feeds.py --config /path/to/config.ini
uv run ./generate_feeds.py -c ./my-config.ini
```
## Docker Usage
A pre-built Docker image is available at `ghcr.io/nikdoof/foursquare-feeds` for easy deployment and automation.
### Quick Start
Run with a local config file:
```bash
$ ./generate_feeds.py -vv --all
5746 checkins to fetch
Fetched checkins 1-250
Fetched checkins 251-500
[etc...]
Fetched checkins 5501-5750
Fetched 5744 checkins from the API
Generated calendar file ./mycalendar.ics
docker run --rm \
-v $(pwd)/config.ini:/app/config/config.ini:ro \
ghcr.io/nikdoof/foursquare-feeds:latest \
--kind ics
```
(No I don't know why it fetched 2 fewer checkins than it says I have.)
### Volume Mounts
The container expects the config file at `/app/config/config.ini`. You can mount your config file using:
- **Bind mount**: `-v /host/path/config.ini:/app/config/config.ini:ro`
- **Volume**: `-v config-volume:/app/config` (with config.ini in the volume)
- **ConfigMap/Secret** (in Kubernetes)
### Container Arguments
The container accepts all the same arguments as the script:
```bash
# Sync to CalDAV
docker run --rm -v $(pwd)/config.ini:/app/config/config.ini:ro \
ghcr.io/nikdoof/foursquare-feeds:latest --kind caldav
# Fetch all check-ins with verbose output
docker run --rm -v $(pwd)/config.ini:/app/config/config.ini:ro \
ghcr.io/nikdoof/foursquare-feeds:latest --all -v
```
### Kubernetes CronJob Example
For automated syncing, you can deploy as a Kubernetes CronJob:
```yaml
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: foursquare-feeds
namespace: jobs
spec:
schedule: "*/5 * * * *" # Every 5 minutes
jobTemplate:
spec:
template:
spec:
containers:
- name: foursquare-feeds
image: ghcr.io/nikdoof/foursquare-feeds:latest
args: ["-k", "caldav"]
volumeMounts:
- name: foursquare-feeds-config
mountPath: /app/config/config.ini
subPath: config.ini
restartPolicy: OnFailure
volumes:
- name: foursquare-feeds-config
secret:
secretName: foursquare-feeds-config
```
This example:
- Runs every 5 minutes
- Syncs check-ins to CalDAV
- Uses a Kubernetes Secret for configuration
- Stores the config as `config.ini` in the secret
## What Gets Exported
Each check-in becomes a calendar event with:
- **Title**: "@ [Venue Name]"
- **Location**: Venue name and address
- **Time**: 15-minute event starting at check-in time
- **Description**: Your shout/comment, plus metadata like:
- Days since last visit
- Mayor status at the time
- **URL**: Link to the check-in on Foursquare
## Privacy Considerations
- Check-ins may contain private information
- If hosting `.ics` files publicly, use obscure filenames
- Consider filtering private check-ins before sharing
## About
By Phil Gyford
phil@gyford.com
https://www.gyford.com
https://github.com/philgyford/foursquare-feeds
**Original Author**: Phil Gyford
**Repository**: https://github.com/philgyford/foursquare-feeds
This tool exists because Foursquare's [official feeds](https://foursquare.com/feeds/) stopped working reliably. [Read more about the original motivation of Phil to create the tool](https://www.gyford.com/phil/writing/2019/05/13/foursquare-swarm-ical-feed/).

View File

@@ -8,5 +8,4 @@ password=xxxxx
calendar_name=Swarm Check-Ins
[Local]
IcsFilepath=./foursquare.ics
KmlFilepath=./foursquare.kml
IcsFilepath=./foursquare.ics

View File

@@ -4,7 +4,6 @@ import configparser
import logging
import os
from datetime import timedelta
from xml.sax.saxutils import escape as xml_escape
import arrow
import caldav
@@ -16,19 +15,19 @@ current_dir = os.path.realpath(os.path.dirname(__file__))
CONFIG_FILE = os.path.join(current_dir, "config.ini")
# The kinds of file we can generate:
VALID_KINDS = ["ics", "kml", "caldav"]
VALID_KINDS = ["ics", "caldav"]
class FeedGenerator:
fetch = "recent"
def __init__(self, fetch="recent"):
def __init__(self, config_file=CONFIG_FILE, fetch="recent"):
"Loads config, sets up Foursquare API client."
self.fetch = fetch
self.logger = logging.getLogger(self.__class__.__name__)
self._load_config(CONFIG_FILE)
self._load_config(config_file)
self.client = foursquare.Foursquare(access_token=self.api_access_token)
@@ -44,7 +43,6 @@ class FeedGenerator:
self.api_access_token = config.get("Foursquare", "AccessToken")
self.ics_filepath = config.get("Local", "IcsFilepath")
self.kml_filepath = config.get("Local", "KmlFilepath")
self.caldav_url = config.get("CalDAV", "url", fallback=None)
self.caldav_username = config.get("CalDAV", "username", fallback=None)
self.caldav_password = config.get("CalDAV", "password", fallback=None)
@@ -64,11 +62,12 @@ class FeedGenerator:
self.logger.info("Fetched {} checkin{} from the API".format(len(checkins), plural))
if kind == "ics":
filepath = self._generate_ics_file(checkins)
elif kind == "kml":
filepath = self._generate_kml_file(checkins)
calendar = self._generate_calendar(checkins)
self.logger.info("Generated file {}".format(filepath))
with open(self.ics_filepath, "w") as f:
f.writelines(calendar)
self.logger.info("Generated file {}".format(self.ics_filepath))
def _get_recent_checkins(self) -> list:
"Make one request to the API for the most recent checkins."
@@ -167,7 +166,7 @@ class FeedGenerator:
continue
venue_name = checkin["venue"]["name"]
tz_offset = self._get_checkin_timezone(checkin)
tz_offset = tzoffset(None, checkin["timeZoneOffset"] * 60)
e = Event()
start = arrow.get(checkin["createdAt"]).replace(tzinfo=tz_offset)
@@ -209,91 +208,6 @@ class FeedGenerator:
return c
def _generate_kml_file(self, checkins):
"""Supplied with a list of checkin data from the API, generates
and saves a kml file.
Returns the filepath of the saved file.
Keyword arguments:
checkins -- A list of dicts, each one data about a single checkin.
"""
import simplekml
user = self._get_user()
kml = simplekml.Kml()
# The original Foursquare files had a Folder with name and
# description like this, so:
names = [user.get("firstName", ""), user.get("lastName", "")]
user_name = " ".join(names).strip()
name = "foursquare checkin history for {}".format(user_name)
fol = kml.newfolder(name=name, description=name)
for checkin in checkins:
if "venue" not in checkin:
# I had some checkins with no data other than
# id, createdAt and source.
continue
venue_name = checkin["venue"]["name"]
tz_offset = self._get_checkin_timezone(checkin)
url = "https://foursquare.com/v/{}".format(checkin["venue"]["id"])
description = ['@<a href="{}">{}</a>'.format(url, venue_name)]
if "shout" in checkin and len(checkin["shout"]) > 0:
description.append('"{}"'.format(checkin["shout"]))
description.append("Timezone offset: {}".format(tz_offset))
coords = [
(
checkin["venue"]["location"]["lng"],
checkin["venue"]["location"]["lat"],
)
]
visibility = 0 if "private" in checkin else 1
pnt = fol.newpoint(
name=venue_name,
description="<![CDATA[{}]]>".format("\n".join(description)),
coords=coords,
visibility=visibility,
# Both of these were set like this in Foursquare's original KML:
altitudemode=simplekml.AltitudeMode.relativetoground,
extrude=1,
)
# Foursquare's KML feeds had 'updated' and 'published' elements
# in the Placemark, but I don't *think* those are standard, so:
pnt.timestamp.when = arrow.get(
checkin["createdAt"],
tzinfo=self._get_checkin_timezone(checkin),
).isoformat()
# Use the address, if any:
if "location" in checkin["venue"]:
loc = checkin["venue"]["location"]
if "formattedAddress" in loc and len(loc["formattedAddress"]) > 0:
address = ", ".join(loc["formattedAddress"])
# While simplexml escapes other strings, it threw a wobbly
# over '&' in addresses, so escape them:
pnt.address = xml_escape(address)
kml.save(self.kml_filepath)
return self.kml_filepath
def _get_checkin_timezone(self, checkin):
"""Given a checkin from the API, returns an arrow timezone object
representing the timezone offset of that checkin.
Keyword arguments
checkin -- A dict of data about a single checkin
"""
return tzoffset(None, checkin["timeZoneOffset"] * 60)
def sync_calendar_to_caldav(self):
"""
Syncs all events from the generated calendar to a CalDAV server.
@@ -328,29 +242,9 @@ class FeedGenerator:
cal = principal.make_calendar(name=self.caldav_calendar_name)
self.logger.debug("Calendar has {} events".format(len(calendar.events)))
# Upload each event from the ics.Calendar object
for event in calendar.events:
# Each event must have a unique UID
# Use the event UID if present, otherwise generate a deterministic one from checkin ID
if not event.uid:
# Try to extract checkin ID from event.url or event.name as fallback
checkin_id = None
if hasattr(event, "url") and event.url:
# URL format: .../checkin/<checkin_id>
parts = event.url.rstrip("/").split("/")
if "checkin" in parts:
idx = parts.index("checkin")
if idx + 1 < len(parts):
checkin_id = parts[idx + 1]
if not checkin_id and hasattr(event, "uid") and event.uid:
# fallback: try to parse from event.uid
if "@" in event.uid:
checkin_id = event.uid.split("@")[0]
if not checkin_id:
# fallback: use event.name
checkin_id = event.name
# Generate a repeatable UID using a namespace and checkin_id
event.uid = "{}@foursquare.com".format(checkin_id)
self.logger.debug("Uploading event with UID: {}".format(event.uid))
cal.add_event(event.serialize())
@@ -375,7 +269,7 @@ def main():
"-k",
"--kind",
action="store",
help="Either ics, kml, or caldav. Default is ics.",
help="Either ics, or caldav. Default is ics.",
choices=VALID_KINDS,
default="ics",
required=False,
@@ -389,6 +283,14 @@ def main():
help="-v or --verbose for brief output; -vv for more.",
required=False,
)
parser.add_argument(
"-c",
"--config",
action="store",
help="Path to config file. Default is 'config.ini' in the current directory.",
required=False,
default=CONFIG_FILE,
)
args = parser.parse_args()
@@ -403,7 +305,7 @@ def main():
else:
to_fetch = "recent"
generator = FeedGenerator(fetch=to_fetch)
generator = FeedGenerator(config_file=args.config, fetch=to_fetch)
if args.kind == "caldav":
generator.sync_calendar_to_caldav()
@@ -411,8 +313,6 @@ def main():
# Generate the requested kind of file
generator.generate(kind=args.kind)
return 0
if __name__ == "__main__":
import sys
sys.exit(main())
sys.exit(main() or 0)

View File

@@ -1,6 +1,6 @@
[project]
name = "foursquare-feeds"
version = "2.3.1"
version = "2.4.0"
description = "A python script for generating an iCal feed from your Foursquare checkins"
readme = "README.md"
requires-python = ">=3.12"
@@ -8,5 +8,11 @@ dependencies = [
"caldav>=2.0.1",
"foursquare",
"ics",
"simplekml",
"requests>=2.32.4",
"urllib3>=2.5.0",
]
[dependency-groups]
dev = [
"ruff>=0.12.8",
]

View File

@@ -1,76 +0,0 @@
# This file was autogenerated by uv via the following command:
# uv export --format requirements-txt --output-file requirements.txt
arrow==1.3.0 \
--hash=sha256:d4540617648cb5f895730f1ad8c82a65f2dad0166f57b75f3ca54759c4d67a85 \
--hash=sha256:c728b120ebc00eb84e01882a6f5e7927a53960aa990ce7dd2b10f39005a67f80
attrs==24.2.0 \
--hash=sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346 \
--hash=sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2
certifi==2024.8.30 \
--hash=sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9 \
--hash=sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8
charset-normalizer==3.4.0 \
--hash=sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e \
--hash=sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6 \
--hash=sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf \
--hash=sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db \
--hash=sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1 \
--hash=sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03 \
--hash=sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284 \
--hash=sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15 \
--hash=sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8 \
--hash=sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2 \
--hash=sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719 \
--hash=sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631 \
--hash=sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b \
--hash=sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565 \
--hash=sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7 \
--hash=sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9 \
--hash=sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114 \
--hash=sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed \
--hash=sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250 \
--hash=sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920 \
--hash=sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64 \
--hash=sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23 \
--hash=sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc \
--hash=sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d \
--hash=sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88 \
--hash=sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90 \
--hash=sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b \
--hash=sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d \
--hash=sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482 \
--hash=sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67 \
--hash=sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b \
--hash=sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079
foursquare==1!2020.1.30 \
--hash=sha256:a9f27c3c426783a18ab96b1872345c9617a5513e0896c0bb4243840fab80eed3 \
--hash=sha256:a5aa9c5b6609ea2610e929288fead0cf27a8bcea2141e2ae7289f6feb57d2041
ics==0.7.2 \
--hash=sha256:6743539bca10391635249b87d74fcd1094af20b82098bebf7c7521df91209f05 \
--hash=sha256:5fcf4d29ec6e7dfcb84120abd617bbba632eb77b097722b7df70e48dbcf26103
idna==3.10 \
--hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \
--hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
pytz==2024.2 \
--hash=sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a \
--hash=sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725
requests==2.32.3 \
--hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \
--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
simplekml==1.3.6 \
--hash=sha256:cda687be2754395fcab664e908ebf589facd41e8436d233d2be37a69efb1c536
six==1.16.0 \
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \
--hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254
tatsu==5.12.2 \
--hash=sha256:5894dc7ddba9a1886a95ff2f06cef1be2b3d3a37c776eba8177ef4dcd80ccb03 \
--hash=sha256:9c313186ae5262662cb3fbec52c9a12db1ef752e615f46cac3eb568cb91eacf9
types-python-dateutil==2.9.0.20241003 \
--hash=sha256:58cb85449b2a56d6684e41aeefb4c4280631246a0da1a719bdbe6f3fb0317446 \
--hash=sha256:250e1d8e80e7bbc3a6c99b907762711d1a1cdd00e978ad39cb5940f6f0a87f3d
urllib3==2.2.3 \
--hash=sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9 \
--hash=sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac

53
uv.lock generated
View File

@@ -123,13 +123,19 @@ wheels = [
[[package]]
name = "foursquare-feeds"
version = "2.3.0"
version = "2.4.0"
source = { virtual = "." }
dependencies = [
{ name = "caldav" },
{ name = "foursquare" },
{ name = "ics" },
{ name = "simplekml" },
{ name = "requests" },
{ name = "urllib3" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
@@ -137,9 +143,13 @@ requires-dist = [
{ name = "caldav", specifier = ">=2.0.1" },
{ name = "foursquare" },
{ name = "ics" },
{ name = "simplekml" },
{ name = "requests", specifier = ">=2.32.4" },
{ name = "urllib3", specifier = ">=2.5.0" },
]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.12.8" }]
[[package]]
name = "icalendar"
version = "6.3.1"
@@ -247,7 +257,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.3"
version = "2.32.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -255,16 +265,35 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
]
[[package]]
name = "simplekml"
version = "1.3.6"
name = "ruff"
version = "0.12.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/e4/c333a93b7e3346437ad1ff42b8e362b853eb405ad6243ab6163f9af2a460/simplekml-1.3.6.tar.gz", hash = "sha256:cda687be2754395fcab664e908ebf589facd41e8436d233d2be37a69efb1c536", size = 52999, upload-time = "2021-09-16T17:08:27.038Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4b/da/5bd7565be729e86e1442dad2c9a364ceeff82227c2dece7c29697a9795eb/ruff-0.12.8.tar.gz", hash = "sha256:4cb3a45525176e1009b2b64126acf5f9444ea59066262791febf55e40493a033", size = 5242373, upload-time = "2025-08-07T19:05:47.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/1e/c843bfa8ad1114fab3eb2b78235dda76acd66384c663a4e0415ecc13aa1e/ruff-0.12.8-py3-none-linux_armv6l.whl", hash = "sha256:63cb5a5e933fc913e5823a0dfdc3c99add73f52d139d6cd5cc8639d0e0465513", size = 11675315, upload-time = "2025-08-07T19:05:06.15Z" },
{ url = "https://files.pythonhosted.org/packages/24/ee/af6e5c2a8ca3a81676d5480a1025494fd104b8896266502bb4de2a0e8388/ruff-0.12.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a9bbe28f9f551accf84a24c366c1aa8774d6748438b47174f8e8565ab9dedbc", size = 12456653, upload-time = "2025-08-07T19:05:09.759Z" },
{ url = "https://files.pythonhosted.org/packages/99/9d/e91f84dfe3866fa648c10512904991ecc326fd0b66578b324ee6ecb8f725/ruff-0.12.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2fae54e752a3150f7ee0e09bce2e133caf10ce9d971510a9b925392dc98d2fec", size = 11659690, upload-time = "2025-08-07T19:05:12.551Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ac/a363d25ec53040408ebdd4efcee929d48547665858ede0505d1d8041b2e5/ruff-0.12.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0acbcf01206df963d9331b5838fb31f3b44fa979ee7fa368b9b9057d89f4a53", size = 11896923, upload-time = "2025-08-07T19:05:14.821Z" },
{ url = "https://files.pythonhosted.org/packages/58/9f/ea356cd87c395f6ade9bb81365bd909ff60860975ca1bc39f0e59de3da37/ruff-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ae3e7504666ad4c62f9ac8eedb52a93f9ebdeb34742b8b71cd3cccd24912719f", size = 11477612, upload-time = "2025-08-07T19:05:16.712Z" },
{ url = "https://files.pythonhosted.org/packages/1a/46/92e8fa3c9dcfd49175225c09053916cb97bb7204f9f899c2f2baca69e450/ruff-0.12.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb82efb5d35d07497813a1c5647867390a7d83304562607f3579602fa3d7d46f", size = 13182745, upload-time = "2025-08-07T19:05:18.709Z" },
{ url = "https://files.pythonhosted.org/packages/5e/c4/f2176a310f26e6160deaf661ef60db6c3bb62b7a35e57ae28f27a09a7d63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:dbea798fc0065ad0b84a2947b0aff4233f0cb30f226f00a2c5850ca4393de609", size = 14206885, upload-time = "2025-08-07T19:05:21.025Z" },
{ url = "https://files.pythonhosted.org/packages/87/9d/98e162f3eeeb6689acbedbae5050b4b3220754554526c50c292b611d3a63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49ebcaccc2bdad86fd51b7864e3d808aad404aab8df33d469b6e65584656263a", size = 13639381, upload-time = "2025-08-07T19:05:23.423Z" },
{ url = "https://files.pythonhosted.org/packages/81/4e/1b7478b072fcde5161b48f64774d6edd59d6d198e4ba8918d9f4702b8043/ruff-0.12.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ac9c570634b98c71c88cb17badd90f13fc076a472ba6ef1d113d8ed3df109fb", size = 12613271, upload-time = "2025-08-07T19:05:25.507Z" },
{ url = "https://files.pythonhosted.org/packages/e8/67/0c3c9179a3ad19791ef1b8f7138aa27d4578c78700551c60d9260b2c660d/ruff-0.12.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:560e0cd641e45591a3e42cb50ef61ce07162b9c233786663fdce2d8557d99818", size = 12847783, upload-time = "2025-08-07T19:05:28.14Z" },
{ url = "https://files.pythonhosted.org/packages/4e/2a/0b6ac3dd045acf8aa229b12c9c17bb35508191b71a14904baf99573a21bd/ruff-0.12.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:71c83121512e7743fba5a8848c261dcc454cafb3ef2934a43f1b7a4eb5a447ea", size = 11702672, upload-time = "2025-08-07T19:05:30.413Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ee/f9fdc9f341b0430110de8b39a6ee5fa68c5706dc7c0aa940817947d6937e/ruff-0.12.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:de4429ef2ba091ecddedd300f4c3f24bca875d3d8b23340728c3cb0da81072c3", size = 11440626, upload-time = "2025-08-07T19:05:32.492Z" },
{ url = "https://files.pythonhosted.org/packages/89/fb/b3aa2d482d05f44e4d197d1de5e3863feb13067b22c571b9561085c999dc/ruff-0.12.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a2cab5f60d5b65b50fba39a8950c8746df1627d54ba1197f970763917184b161", size = 12462162, upload-time = "2025-08-07T19:05:34.449Z" },
{ url = "https://files.pythonhosted.org/packages/18/9f/5c5d93e1d00d854d5013c96e1a92c33b703a0332707a7cdbd0a4880a84fb/ruff-0.12.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:45c32487e14f60b88aad6be9fd5da5093dbefb0e3e1224131cb1d441d7cb7d46", size = 12913212, upload-time = "2025-08-07T19:05:36.541Z" },
{ url = "https://files.pythonhosted.org/packages/71/13/ab9120add1c0e4604c71bfc2e4ef7d63bebece0cfe617013da289539cef8/ruff-0.12.8-py3-none-win32.whl", hash = "sha256:daf3475060a617fd5bc80638aeaf2f5937f10af3ec44464e280a9d2218e720d3", size = 11694382, upload-time = "2025-08-07T19:05:38.468Z" },
{ url = "https://files.pythonhosted.org/packages/f6/dc/a2873b7c5001c62f46266685863bee2888caf469d1edac84bf3242074be2/ruff-0.12.8-py3-none-win_amd64.whl", hash = "sha256:7209531f1a1fcfbe8e46bcd7ab30e2f43604d8ba1c49029bb420b103d0b5f76e", size = 12740482, upload-time = "2025-08-07T19:05:40.391Z" },
{ url = "https://files.pythonhosted.org/packages/cb/5c/799a1efb8b5abab56e8a9f2a0b72d12bd64bb55815e9476c7d0a2887d2f7/ruff-0.12.8-py3-none-win_arm64.whl", hash = "sha256:c90e1a334683ce41b0e7a04f41790c429bf5073b62c1ae701c9dc5b3d14f0749", size = 11884718, upload-time = "2025-08-07T19:05:42.866Z" },
]
[[package]]
name = "six"
@@ -304,11 +333,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.2.3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" },
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]