From 464964e2d0db4a4cf4fb4a9a2ca972eea5d1b29c Mon Sep 17 00:00:00 2001 From: Marc <128506536+MarcChen@users.noreply.github.com> Date: Thu, 13 Nov 2025 22:31:25 +0100 Subject: [PATCH 1/9] chore: update .gitignore --- .gitignore | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index d03c1b0..98d743b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,59 @@ -.garth/* +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ +.python-version + +# UV +.uv/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs *.log -*.json -*.lock \ No newline at end of file + +# Project specific +.garth/ +installed_packages.json +backup_path.json +*.fit + +# Lock files (exclude Pipfile.lock if migrating) +Pipfile.lock + +# OS +.DS_Store +Thumbs.db + +data/ +.env +**token** +strava.db \ No newline at end of file From 01f82a2e1bdd9ab76f22b2ea8f5d643c5952744a Mon Sep 17 00:00:00 2001 From: Marc <128506536+MarcChen@users.noreply.github.com> Date: Thu, 13 Nov 2025 22:34:07 +0100 Subject: [PATCH 2/9] chore: replacing pipfile by uv and some previous script --- .pre-commit-config.yaml | 18 + MyWhooshMonitor.ps1 | 44 --- Pipfile | 11 - apple-script/MyWhoosh2Garmin-AS.scpt | Bin 2610 -> 0 bytes apple-script/README.md | 44 --- pyproject.toml | 47 +++ uv.lock | 525 +++++++++++++++++++++++++++ 7 files changed, 590 insertions(+), 99 deletions(-) create mode 100644 .pre-commit-config.yaml delete mode 100644 MyWhooshMonitor.ps1 delete mode 100644 Pipfile delete mode 100644 apple-script/MyWhoosh2Garmin-AS.scpt delete mode 100644 apple-script/README.md create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..12e5c2a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: check-json + - id: check-toml + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.3 + hooks: + # Run the linter + - id: ruff + args: [--fix] + # Run the formatter + - id: ruff-format diff --git a/MyWhooshMonitor.ps1 b/MyWhooshMonitor.ps1 deleted file mode 100644 index 7889bda..0000000 --- a/MyWhooshMonitor.ps1 +++ /dev/null @@ -1,44 +0,0 @@ -# Define the JSON config file path -$configFile = "$PSScriptRoot\mywhoosh_config.json" -$myWhooshApp = "myWhoosh Indoor Cycling App.app" - -# Check if the JSON file exists and read the stored path -if (Test-Path $configFile) { - $config = Get-Content -Path $configFile | ConvertFrom-Json - $mywhooshPath = $config.path -} else { - $mywhooshPath = $null -} - -# Validate the stored path -if (-not $mywhooshPath -or -not (Test-Path $mywhooshPath)) { - Write-Host "Searching for $myWhooshApp" - $mywhooshPath = Get-ChildItem -Path "/Applications" -Filter $myWhooshApp -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - - if (-not $mywhooshPath) { - Write-Host " not found!" - exit 1 - } - - $mywhooshPath = $mywhooshPath.FullName - - # Store the path in the JSON file - $config = @{ path = $mywhooshPath } - $config | ConvertTo-Json | Set-Content -Path $configFile -} - -Write-Host "Found $myWhooshApp at $mywhooshPath" - -# Start mywhoosh.exe -Start-Process -FilePath $mywhooshPath - -# Wait for the application to finish -Write-Host "Waiting for $myWhooshApp to finish..." -while ($process = ps -ax | grep -i $myWhooshApp | grep -v "grep") { - Write-Output $process - Start-Sleep -Seconds 5 -} - -# Run the Python script -Write-Host "$myWhooshApp has finished, running Python script..." -python3 /Users/jayqueue/Development/Python/MyWhoosh2Garmin/myWhoosh2Garmin.py diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 1f83237..0000000 --- a/Pipfile +++ /dev/null @@ -1,11 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -garth = "0.5.2" -fit_tool = "0.9.13" - -[requires] -python_version = "3.13" diff --git a/apple-script/MyWhoosh2Garmin-AS.scpt b/apple-script/MyWhoosh2Garmin-AS.scpt deleted file mode 100644 index b39033d24dd77a0a94a32c3e98cce419ff19af49..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2610 zcmb7GX;)KM6y5IyA-u;8M5~|{4b~A6gNRlut*D^3Do$M;zJ>6VAS5vfEcw=-&_B`- zPIant9b4ylD2nquw9d9Y-S-8t?OJ9@;O^Xe?tS;}v+p_Qr7o0g{IX_AprX7YsGAQ! zR=x-fnD7|vK(TMEpk05mOk{DU53&`(Y<7*N{EZx&Fd&HBR4B3BNrmF^H{F_eTp5zGje<lTNMnl=ZEZ-nyUO>7wzaev@{GI5 zQ+@A+Z|=PcMJ9^4$v_NJgsgA%Bbb1}_z=bNM6qL5yNBdq2!<+x8FklKys{ESFe*6q z!jeDuk1R`Tp-=*IqPxy3zhjt1$g;u|N?{m`7-vkX^4ONg8DeW_T0cgEJhJ3bw|=Qa zo1;k2)b8a|g^w^?5!^WBEy+Vm9{y+T@brmXE)Q&Zkm1fu6qDEpj8p`d=0FUQ`?lQA z_|Ql`G)nF%j7kez!oV4AVl>TTP^t(n*%DgDqD&E7vLzTMcP+X5ufR|!drS6?Eq5}W zTgn5*$!&#kIuyWj%H@{alpD6(LTM2F;!a{&EYTVYry{XvEb6R^q+MC7}rpi^2-_9kjD0n4SU7 z28l^`F6I`Iq$$@myZ&qwQKbkyX>?VtNS7g3wF_4?bm^56T0-Rw_(S$CH6T29GHOHNagkkg)8*%nFZ%A_Tco+@=pi+NIuSqEdjD^3n(E0*P+ zeNM=6IcCX;jQbqd`yA8MX0gqYqY880I&nk}%OOLKX#R&a9Ma2=>X791~0yKIxKmh9+$ z4Op_h`&D7fwqCLK$W{#T%T7!xz1+Y&!T7E+hjk{;sSR^zeuKfNPB6yj06Z*icCAd@g^Zt87Gb9!wIOr-}WDI$1C4un3D~t;J!cWLd9itkVn@X;{oaUSr9c4A1yw YExcoz8%qoH%QM6uQWQ(Q>g@dCAN~y9Z~y=R diff --git a/apple-script/README.md b/apple-script/README.md deleted file mode 100644 index e27800c..0000000 --- a/apple-script/README.md +++ /dev/null @@ -1,44 +0,0 @@ -

Apple Script to automate Garmin Upload

-

The app will run and constantly check (every 30 seconds) whether MyWhoosh is running. Once started, it will listen to My Whoosh being quit/exited. In case My Whoosh was exited/quit, it will run the myWhoosh2Garmin.py script that needs to be installed and setup previously.

-

πŸ› οΈ Installation Steps:

-
    -
  1. Download MyWhoosh2Garmin-AS.scpt to your filesystem to a folder of your choosing.
  2. -
  3. Go to the folder where you downloaded the script via Mac Finder.
  4. -
  5. Open the script in the Apple Script Editor and set the property pythonScriptPath to the location where you downloaded the - myWhoosh2Garmin.py script.
  6. - -``` -property targetApp : "MyWhoosh Indoor Cycling App" -property pythonScriptPath : "/path/to/myWhoosh2Garmin.py" -property appRunning : false - -on idle - if application targetApp is running then - set appRunning to true - else if appRunning then - set appRunning to false - performActionOnExit() - end if - return 30 -- Check every 30 seconds -end idle - -on performActionOnExit() - do shell script "python3 " & quoted form of pythonScriptPath -end performActionOnExit - -on quit - continue quit -end quit - -``` - -
  7. After changing the property file, export the file as an app.
  8. -
  9. Please select File Format Application.
  10. -
  11. Please select Stay open after run handler an export option.
  12. -
  13. Store the app at a location of your choice. - Xnip2024-12-30_22-16-55 -
  14. -
  15. Before running the script, you need to grant the app full access to your hard drive. Otherwise, you will be prompted each time to allow access when the myWhoosh2Garmin is executed. Please search the web for a guide, given it slightly depends on your Mac OS version. See screenshot of my setup (I gave my App the My Whoosh icon): Xnip2024-12-30_22-28-22 -
  16. -
  17. Now you can run the App and start riding on My Whoosh. After you exit My Whoosh the Garmin upload script will be exectuded.
  18. -
diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1cd32eb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "mywhoosh2garmin" +version = "0.1.0" +description = "Upload MyWhoosh activities to Garmin Connect with proper fixes" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "pydantic-settings==2.11.0", + "pydantic==2.12.4", + "garth==0.5.2", + "fit-tool==0.9.13", + "playwright==1.55.0", +] + +[project.optional-dependencies] +dev = [ + "pre-commit>=3.5.0", + "ruff>=0.14.3", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +line-length = 88 +target-version = "py313" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] # unused imports in __init__.py + +[tool.ruff.lint.isort] +known-first-party = ["mywhoosh2garmin"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..103085d --- /dev/null +++ b/uv.lock @@ -0,0 +1,525 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "bitstruct" +version = "8.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/33/9f094b5e32bc0927acf282199d35c384092dd73505c88fadb69292106eaf/bitstruct-8.11.1.tar.gz", hash = "sha256:4e7b8769c0f09fee403d0a5f637f8b575b191a79a92e140811aa109ce7461f0c", size = 34140, upload-time = "2020-11-19T09:30:04.021Z" } + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "fit-tool" +version = "0.9.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitstruct" }, + { name = "openpyxl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/8f/9446c4eb9a8e5720b18848b3036653c4fdd0b58746e2c62f1eef05e7632b/fit-tool-0.9.13.tar.gz", hash = "sha256:63d5655dbacf4121178e7743ad4cf0d980abd53da6316a419c205941ce049c55", size = 137841, upload-time = "2022-10-05T18:31:20.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/c1/27e6ff55c0bca9b61b374f72dad8143908b1d1e792e04e2dbd74ade26857/fit_tool-0.9.13-py3-none-any.whl", hash = "sha256:15269280635ff3a4baedfe1346840767f660817f136f4dec69b858ee855a9e9c", size = 224079, upload-time = "2022-10-05T18:31:18.298Z" }, +] + +[[package]] +name = "garth" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/a9/872cba34553b66afb86c2091718e05b4acd34cd4e09bea6c13af9b465e6f/garth-0.5.2.tar.gz", hash = "sha256:594acafe27989daa3ffbc8460caf063802359c6b1f40c98ffd3b21f69adc6e09", size = 1733749, upload-time = "2024-12-12T12:18:26.004Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/38/dbdecb82297cfbd38920d0e009eaf47cb30eade41b529fc3cd831a20ec5b/garth-0.5.2-py3-none-any.whl", hash = "sha256:bd0297ef82afdf06e10ed0c271c9270bbf6bec832d62d108a325a8ac4723d74a", size = 24492, upload-time = "2024-12-12T12:18:23.027Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "jdcal" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/b0/fa20fce23e9c3b55b640e629cb5edf32a85e6af3cf7af599940eb0c753fe/jdcal-1.4.1.tar.gz", hash = "sha256:472872e096eb8df219c23f2689fc336668bdb43d194094b5cc1707e1640acfc8", size = 7479, upload-time = "2019-04-24T10:22:15.079Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/da/572cbc0bc582390480bbd7c4e93d14dc46079778ed915b505dc494b37c57/jdcal-1.4.1-py2.py3-none-any.whl", hash = "sha256:1abf1305fce18b4e8aa248cf8fe0c56ce2032392bc64bbd61b5dff2a19ec8bba", size = 9522, upload-time = "2019-04-24T10:22:13.201Z" }, +] + +[[package]] +name = "mywhoosh2garmin" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fit-tool" }, + { name = "garth" }, + { name = "playwright" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fit-tool", specifier = "==0.9.13" }, + { name = "garth", specifier = "==0.5.2" }, + { name = "playwright", specifier = "==1.55.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, + { name = "pydantic", specifier = "==2.12.4" }, + { name = "pydantic-settings", specifier = "==2.11.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.3" }, +] +provides-extras = ["dev"] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "openpyxl" +version = "2.5.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, + { name = "jdcal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/8a/509eb6f58672288da9a5884e1cc7e90819bc8dbef501161c4b40a6a4e46b/openpyxl-2.5.12.tar.gz", hash = "sha256:7bcf019a0be528673a8aec1e60b5c863342c3231962dbf7922fd4da42a49a91a", size = 173659, upload-time = "2018-11-29T11:29:14.739Z" } + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "playwright" +version = "1.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/3a/c81ff76df266c62e24f19718df9c168f49af93cabdbc4608ae29656a9986/playwright-1.55.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034", size = 40428109, upload-time = "2025-08-28T15:46:20.357Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f5/bdb61553b20e907196a38d864602a9b4a461660c3a111c67a35179b636fa/playwright-1.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", size = 38687254, upload-time = "2025-08-28T15:46:23.925Z" }, + { url = "https://files.pythonhosted.org/packages/4a/64/48b2837ef396487807e5ab53c76465747e34c7143fac4a084ef349c293a8/playwright-1.55.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", size = 40428108, upload-time = "2025-08-28T15:46:27.119Z" }, + { url = "https://files.pythonhosted.org/packages/08/33/858312628aa16a6de97839adc2ca28031ebc5391f96b6fb8fdf1fcb15d6c/playwright-1.55.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", size = 45905643, upload-time = "2025-08-28T15:46:30.312Z" }, + { url = "https://files.pythonhosted.org/packages/83/83/b8d06a5b5721931aa6d5916b83168e28bd891f38ff56fe92af7bdee9860f/playwright-1.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", size = 45296647, upload-time = "2025-08-28T15:46:33.221Z" }, + { url = "https://files.pythonhosted.org/packages/06/2e/9db64518aebcb3d6ef6cd6d4d01da741aff912c3f0314dadb61226c6a96a/playwright-1.55.0-py3-none-win32.whl", hash = "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", size = 35476046, upload-time = "2025-08-28T15:46:36.184Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/9ba607fa94bb9cee3d4beb1c7b32c16efbfc9d69d5037fa85d10cafc618b/playwright-1.55.0-py3-none-win_amd64.whl", hash = "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", size = 35476048, upload-time = "2025-08-28T15:46:38.867Z" }, + { url = "https://files.pythonhosted.org/packages/21/98/5ca173c8ec906abde26c28e1ecb34887343fd71cc4136261b90036841323/playwright-1.55.0-py3-none-win_arm64.whl", hash = "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", size = 31225543, upload-time = "2025-08-28T15:46:41.613Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501, upload-time = "2025-11-08T21:12:11.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] From f978f2fe82bbdd204b78439e5095a80c3f9d195c Mon Sep 17 00:00:00 2001 From: Marc <128506536+MarcChen@users.noreply.github.com> Date: Thu, 13 Nov 2025 22:34:55 +0100 Subject: [PATCH 3/9] feat: add main components for automatic download and push to garmin connect --- fit_utils/fit_builder.py | 394 +++++++++++++++++++++++++++ garmin/utils.py | 139 ++++++++++ myWhoosh2Garmin.py | 484 +++------------------------------- strava/{main.py => client.py} | 152 +++++++---- 4 files changed, 668 insertions(+), 501 deletions(-) create mode 100644 fit_utils/fit_builder.py create mode 100644 garmin/utils.py rename strava/{main.py => client.py} (74%) diff --git a/fit_utils/fit_builder.py b/fit_utils/fit_builder.py new file mode 100644 index 0000000..015fcde --- /dev/null +++ b/fit_utils/fit_builder.py @@ -0,0 +1,394 @@ +import json +from datetime import datetime +from pathlib import Path +from typing import List + +from fit_tool.fit_file_builder import FitFileBuilder +from fit_tool.profile.messages.activity_message import ActivityMessage +from fit_tool.profile.messages.event_message import EventMessage +from fit_tool.profile.messages.file_creator_message import FileCreatorMessage +from fit_tool.profile.messages.file_id_message import FileIdMessage +from fit_tool.profile.messages.lap_message import LapMessage +from fit_tool.profile.messages.record_message import RecordMessage +from fit_tool.profile.messages.session_message import SessionMessage +from fit_tool.profile.profile_type import ( + Event, + EventType, + FileType, + GarminProduct, + Intensity, + Manufacturer, + Sport, + SubSport, +) +from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator + + +class ActivityData(BaseModel): + """Model for MyWhoosh activity JSON data.""" + + model_config = ConfigDict( + extra="forbid", # Forbid extra fields + validate_assignment=True, # Validate on attribute assignment + str_to_lower=True, # Convert strings to lowercase + strict=True, # Enforce strict type checking + ) + + # From activity metadata api + name: str = Field(default_factory=str, alias="strava_activity_name") + id: int = Field(..., alias="strava_activity_id") + activity_distance: float + moving_time: int + elapsed_time: int + total_elevation_gain: float + type: str + start_date: datetime | int + start_date_local: datetime | int + timezone: str + utc_offset: float + average_speed: float + max_speed: float + average_cadence: float + average_watts: float + max_watts: int + weighted_average_watts: int + kilojoules: float + average_heartrate: float + max_heartrate: float + calories: float + + # From streams + lat: List[float] + long: List[float] + watts: List[int] + cadence: List[int] + velocity_smooth: List[float] + heartrate: List[int] + time: List[int] + heartrates: List[int] + distance: List[float] + grade_smooth: List[float] | None + altitude: List[float] | None + + @model_validator(mode="after") + def validate_streams(self) -> "ActivityData": + """Validate that all stream lists have the same length and records exist.""" + # Collect all stream attributes that are lists + stream_attrs = [ + "lat", + "long", + "watts", + "cadence", + "velocity_smooth", + "heartrate", + "time", + "heartrates", + "distance", + ] + # Optionally include nullable streams if present + if self.grade_smooth is not None: + stream_attrs.append("grade_smooth") + if self.altitude is not None: + stream_attrs.append("altitude") + + lengths = [ + len(getattr(self, attr)) + for attr in stream_attrs + if getattr(self, attr) is not None + ] + if lengths and any(length != lengths[0] for length in lengths): + raise ValueError("All stream lists must have the same length.") + + return self + + @property + def stream_length(self) -> int: + return len(self.time) + + @property + def elapsed_time(self) -> int: + """Get elapsed time in milliseconds.""" + return self.elapsed_time * 1000 + + @classmethod + def from_json_file(cls, json_file_path: str) -> "ActivityData": + """Load and parse the JSON activity file into the model.""" + with open(json_file_path, "r") as f: + raw_data = json.load(f) + + # Extract metadata and streams + metadata = raw_data.get("metadata", {}) + streams = raw_data.get("streams", {}) + + # Combine metadata fields with stream data + combined_data = { + # Metadata fields (activity summary) + "strava_activity_name": metadata.get("name", ""), + "strava_activity_id": metadata.get("id"), + "activity_distance": metadata.get("distance"), + "moving_time": metadata.get("moving_time"), + "elapsed_time": metadata.get("elapsed_time"), + "total_elevation_gain": metadata.get("total_elevation_gain"), + "type": metadata.get("type"), + "start_date": datetime.fromisoformat(metadata.get("start_date")) + if metadata.get("start_date") + else None, + "start_date_local": datetime.fromisoformat(metadata.get("start_date_local")) + if metadata.get("start_date_local") + else None, + "timezone": metadata.get("timezone"), + "utc_offset": metadata.get("utc_offset"), + "average_speed": metadata.get("average_speed"), + "max_speed": metadata.get("max_speed"), + "average_cadence": metadata.get("average_cadence"), + "average_watts": metadata.get("average_watts"), + "max_watts": metadata.get("max_watts"), + "weighted_average_watts": metadata.get("weighted_average_watts"), + "kilojoules": metadata.get("kilojoules"), + "average_heartrate": metadata.get("average_heartrate"), + "max_heartrate": metadata.get("max_heartrate"), + "calories": metadata.get("calories"), + # Stream data (time series) + # Extract and separate lat/long from latlng pairs + "lat": [], + "long": [], + } + + # Stream data (time series) + # Extract and separate lat/long from latlng pairs + latlng_data = streams.get("latlng", {}).get("data", []) + if latlng_data: + lat_values, long_values = zip(*latlng_data) + combined_data["lat"] = list(lat_values) + combined_data["long"] = list(long_values) + else: + combined_data["lat"] = [] + combined_data["long"] = [] + + combined_data.update( + { + "watts": streams.get("watts", {}).get("data", []), + "cadence": streams.get("cadence", {}).get("data", []), + "velocity_smooth": streams.get("velocity_smooth", {}).get("data", []), + "heartrate": streams.get("heartrate", {}).get("data", []), + "time": streams.get("time", {}).get("data", []), + "heartrates": streams.get("heartrate", {}).get("data", []), + "distance": streams.get("distance", {}).get("data", []), + "grade_smooth": streams.get("grade_smooth", {}).get("data"), + "altitude": streams.get("altitude", {}).get("data"), + } + ) + + return cls(**combined_data) + + @computed_field + def max_cadence(self) -> int: + return max(self.cadence) if self.cadence else 0 + + @computed_field + @property + def start_ts_miliseconds(self) -> int: + return round(self.start_date.timestamp()) * 1000 + + +class MyWhooshFitBuilder: + """Convert MyWhoosh activity JSON to FIT file format.""" + + def __init__(self, json_file_path: str): + """Initialize with path to MyWhoosh JSON file.""" + self.json_path = json_file_path + self.activity_data = ActivityData.from_json_file(json_file_path) + self.builder = FitFileBuilder(auto_define=True) + self.end_date_fit_ts = ( + self.activity_data.start_ts_miliseconds + + 1000 * self.activity_data.stream_length + ) + + def _add_file_id(self): + """Add file_id message.""" + file_id = FileIdMessage() + file_id.type = FileType.ACTIVITY + file_id.manufacturer = Manufacturer.GARMIN.value + file_id.product = GarminProduct.EDGE_530.value + file_id.serial_number = 3313379353 + file_id.time_created = self.activity_data.start_ts_miliseconds + self.builder.add(file_id) + + def _add_file_creator(self): + """Add file creator message.""" + file_creator = FileCreatorMessage() + file_creator.software_version = 29 + self.builder.add(file_creator) + + def _add_event(self, timestamp: int, event: Event, event_type: EventType): + """Add event message.""" + event_msg = EventMessage() + event_msg.timestamp = timestamp + event_msg.event = event + event_msg.event_type = event_type + self.builder.add(event_msg) + + def _add_records(self): + """Add all record messages from the activity data.""" + if not self.activity_data or self.activity_data.stream_length == 0: + return + + for i in range(self.activity_data.stream_length): + record = RecordMessage() + + # Timestamp - time[i] est en secondes, on convertit en millisecondes + record.timestamp = self.activity_data.start_ts_miliseconds + ( + self.activity_data.time[i] * 1000 + ) + + # Position (lat/long en degrΓ©s) + record.position_lat = self.activity_data.lat[i] + record.position_long = self.activity_data.long[i] + + # Heart rate + record.heart_rate = self.activity_data.heartrate[i] + + # Cadence + record.cadence = self.activity_data.cadence[i] + + # Distance (meters) + record.distance = self.activity_data.distance[i] + + # Altitude (meters) - optional + if self.activity_data.altitude is not None: + record.altitude = self.activity_data.altitude[i] + + # Power (watts) + record.power = self.activity_data.watts[i] + + # Speed (m/s) + record.speed = self.activity_data.velocity_smooth[i] + + self.builder.add(record) + + def _add_lap(self): + """Add lap message.""" + lap = LapMessage() + + lap.timestamp = ( + self.activity_data.start_ts_miliseconds + self.activity_data.elapsed_time + ) + lap.start_time = self.activity_data.start_ts_miliseconds + lap.total_elapsed_time = self.activity_data.elapsed_time + lap.total_timer_time = self.activity_data.elapsed_time + lap.intensity = Intensity.ACTIVE + lap.total_distance = self.activity_data.activity_distance + lap.avg_heart_rate = int(self.activity_data.average_heartrate) + lap.max_heart_rate = int(self.activity_data.max_heartrate) + + lap.avg_cadence = int(self.activity_data.average_cadence) + lap.max_cadence = int(self.activity_data.max_cadence) + + lap.avg_power = int(self.activity_data.average_watts) + lap.max_power = int(self.activity_data.max_watts) + + lap.avg_speed = self.activity_data.average_speed + lap.max_speed = self.activity_data.max_speed + + lap.total_calories = int(self.activity_data.calories) + lap.sport = Sport.CYCLING + lap.sub_sport = SubSport.VIRTUAL_ACTIVITY + + self.builder.add(lap) + + def _add_session(self): + """Add session message.""" + session = SessionMessage() + + session.timestamp = self.end_date_fit_ts + session.start_time = self.activity_data.start_ts_miliseconds + session.total_elapsed_time = self.activity_data.elapsed_time + session.total_timer_time = self.activity_data.elapsed_time + session.total_distance = self.activity_data.activity_distance + + session.avg_heart_rate = int(self.activity_data.average_heartrate) + session.max_heart_rate = int(self.activity_data.max_heartrate) + + session.avg_cadence = int(self.activity_data.average_cadence) + session.max_cadence = int(self.activity_data.max_cadence) + + session.avg_power = int(self.activity_data.average_watts) + session.max_power = int(self.activity_data.max_watts) + + session.avg_speed = self.activity_data.average_speed + session.max_speed = self.activity_data.max_speed + + session.total_calories = int(self.activity_data.calories) + session.sport = Sport.CYCLING + session.sub_sport = SubSport.VIRTUAL_ACTIVITY + session.first_lap_index = 0 + session.num_laps = 1 + + self.builder.add(session) + + def _add_activity(self): + """Add activity message.""" + activity = ActivityMessage() + activity.timestamp = self.end_date_fit_ts + activity.total_timer_time = self.activity_data.elapsed_time + activity.num_sessions = 1 + activity.type = 0 + activity.event = Event.ACTIVITY + activity.event_type = EventType.STOP + activity.local_timestamp = round(self.activity_data.start_date.timestamp()) + self.builder.add(activity) + + def build(self, output_path: str = None): + """Build and write the FIT file.""" + if not output_path: + raise ValueError("output_path is required for build.") + + # Add messages in order + self._add_file_id() + self._add_file_creator() + + # Timer start event + self._add_event( + self.activity_data.start_ts_miliseconds, Event.TIMER, EventType.START + ) + + # Add all record points + self._add_records() + + # Add lap + self._add_lap() + + # Timer stop event + self._add_event(self.end_date_fit_ts, Event.SESSION, EventType.STOP_DISABLE_ALL) + + # Add session and activity + self._add_session() + self._add_activity() + + # Build FIT file and write to disk + fit_file = self.builder.build() + fit_file.to_file(output_path) + + print(f"FIT file saved to: {output_path}") + + +# Example usage +if __name__ == "__main__": + # # Example usage + data_dir = Path(__file__).parent.parent / "data" + input_file = str( + data_dir / "raw" / "MyWhoosh - The Seven Gems_2025-11-13_combined.json" + ) + output_file = str( + data_dir / "processed" / "MyWhoosh - The Seven Gems_2025-11-13_combined.fit" + ) + + data = ActivityData.from_json_file(input_file) + + # Create builder and generate FIT file + builder = MyWhooshFitBuilder(input_file) + builder.build(output_file) + + # print("FIT file created successfully!") + +# What I need to retrieve from the json : diff --git a/garmin/utils.py b/garmin/utils.py new file mode 100644 index 0000000..126dba7 --- /dev/null +++ b/garmin/utils.py @@ -0,0 +1,139 @@ +import logging +import sys +from datetime import datetime, timedelta +from pathlib import Path +from typing import List, Tuple + +import garth +from garth.exc import GarthException, GarthHTTPError +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +logger = logging.getLogger(__name__) + + +class GarminSettings(BaseSettings): + """Configuration settings for Garmin API client.""" + + garmin_username: str = Field(..., validation_alias="GARMIN_USERNAME") + garmin_password: str = Field(..., validation_alias="GARMIN_PASSWORD") + garmin_tokens_path: Path = Field( + default=Path(__file__).parent.parent / ".garth", + ) + + model_config = SettingsConfigDict( + env_file=Path(__file__).parent.parent / ".env", extra="ignore" + ) + + +def get_credentials_for_garmin(garmin_settings: GarminSettings = GarminSettings()): + """ + Prompt the user for Garmin credentials and authenticate using Garth. + + Returns: + None + + Exits: + Exits with status 1 if authentication fails. + """ + logger.info("Authenticating...") + try: + garth.login( + garmin_settings.garmin_username, + garmin_settings.garmin_password, + prompt_mfa=lambda: input("Enter MFA code: "), + ) + garth.save(garmin_settings.garmin_tokens_path) + print() + logger.info("Successfully authenticated!") + except GarthHTTPError: + logger.info("Wrong credentials. Please check username and password.") + sys.exit(1) + + +def authenticate_to_garmin(garmin_settings: GarminSettings = GarminSettings()): + """ + Authenticate the user to Garmin by checking for existing tokens and + resuming the session, or prompting for credentials if no session + exists or the session is expired. + + Returns: + None + + Exits: + Exits with status 1 if authentication fails. + """ + try: + if garmin_settings.garmin_tokens_path.exists(): + garth.resume(garmin_settings.garmin_tokens_path) + try: + logger.info(f"Authenticated as: {garth.client.username}") + except GarthException: + logger.info("Session expired. Re-authenticating...") + get_credentials_for_garmin(garmin_settings) + else: + logger.info("No existing session. Please log in.") + get_credentials_for_garmin(garmin_settings) + except GarthException as e: + logger.info(f"Authentication error: {e}") + sys.exit(1) + + +def upload_fit_file_to_garmin(new_file_path: Path): + """ + Upload a .fit file to Garmin using the Garth client. + + Args: + new_file_path (Path): The path to the .fit file to upload. + + Returns: + None + """ + try: + if new_file_path and new_file_path.exists(): + with open(new_file_path, "rb") as f: + uploaded = garth.client.upload(f) + logger.debug(uploaded) + else: + logger.info(f"Invalid file path: {new_file_path}.") + except GarthHTTPError: + logger.info("Duplicate activity found on Garmin Connect.") + + +def list_virtual_cycling_activities( + last_n_days: int = 30, +) -> Tuple[List[str], List[datetime]]: + """Return two lists: activity names and start times of virtual cycling activities from Garmin Connect.""" + logger.info( + f"Retrieving virtual cycling activities from the last {last_n_days} days..." + ) + end_date = datetime.now().date() + start_date = end_date - timedelta(days=last_n_days) + activities = garth.connectapi( + "/activitylist-service/activities/search/activities", + params={ + "startDate": start_date.strftime("%Y-%m-%d"), + "endDate": end_date.strftime("%Y-%m-%d"), + }, + ) + names, start_times = [], [] + for activity in activities: + if activity.get("activityType", {}).get("typeKey") == "virtual_ride": + names.append(activity["activityName"]) + start_time_str = activity.get("startTimeLocal", "") + try: + start_time = datetime.strptime(start_time_str, "%Y-%m-%d %H:%M:%S") + except (ValueError, TypeError): + start_time = None + start_times.append(start_time) + logger.debug( + f"Found virtual cycling activity: {activity['activityName']} at {activity.get('startTimeLocal', '')} with elapsed time {activity.get('elapsedTime', '')}." + ) + return names, start_times + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + authenticate_to_garmin() + # Example usage: + list_virtual_cycling_activities() diff --git a/myWhoosh2Garmin.py b/myWhoosh2Garmin.py index eec756f..ffb50e8 100644 --- a/myWhoosh2Garmin.py +++ b/myWhoosh2Garmin.py @@ -1,469 +1,53 @@ -#!/usr/bin/env python3 -""" -Script name: myWhoosh2Garmin.py -Usage: "python3 myWhoosh2Garmin.py" -Description: Checks for MyNewActivity-.fit - Adds avg power and heartrade - Removes temperature - Creates backup for the file with a timestamp as a suffix -Credits: Garth by matin - for authenticating and uploading with - Garmin Connect. - https://github.com/matin/garth - Fit_tool by mtucker - for parsing the fit file. - https://bitbucket.org/stagescycling/python_fit_tool.git/src - mw2gc by embeddedc - used as an example to fix the avg's. - https://github.com/embeddedc/mw2gc -""" -import os -import json -import subprocess -import sys import logging -import re -from typing import List -import tkinter as tk -from tkinter import filedialog from datetime import datetime -from getpass import getpass from pathlib import Path -import importlib.util +from fit_utils.fit_builder import MyWhooshFitBuilder +from garmin.utils import authenticate_to_garmin, list_virtual_cycling_activities +from strava.client import StravaClientBuilder SCRIPT_DIR = Path(__file__).resolve().parent log_file_path = SCRIPT_DIR / "myWhoosh2Garmin.log" -json_file_path = SCRIPT_DIR / "backup_path.json" +RAW_FIT_FILE_PATH = SCRIPT_DIR / "data" / "raw" logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) file_handler = logging.FileHandler(log_file_path) -formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') +formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") file_handler.setFormatter(formatter) logger.addHandler(file_handler) -INSTALLED_PACKAGES_FILE = SCRIPT_DIR / "installed_packages.json" - - -def load_installed_packages(): - """Load the set of installed packages from a JSON file.""" - if INSTALLED_PACKAGES_FILE.exists(): - with INSTALLED_PACKAGES_FILE.open("r") as f: - return set(json.load(f)) - return set() - - -def save_installed_packages(installed_packages): - """Save the set of installed packages to a JSON file.""" - with INSTALLED_PACKAGES_FILE.open("w") as f: - json.dump(list(installed_packages), f) - - -def get_pip_command(): - """Return the pip command if pip is available.""" - try: - subprocess.check_call( - [sys.executable, "-m", "pip", "--version"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - return [sys.executable, "-m", "pip"] - except subprocess.CalledProcessError: - return None - - -def install_package(package): - """Install the specified package using pip.""" - pip_command = get_pip_command() - if pip_command: - try: - logger.info(f"Installing missing package: {package}.") - subprocess.check_call( - pip_command + ["install", package] - ) - except subprocess.CalledProcessError as e: - logger.error(f"Error installing {package}: {e}.") - else: - logger.debug("pip is not available. Unable to install packages.") - - -def ensure_packages(): - """Ensure all required packages are installed and tracked.""" - required_packages = ["garth", "fit_tool"] - installed_packages = load_installed_packages() - - for package in required_packages: - if package in installed_packages: - logger.info(f"Package {package} is already tracked as installed.") - continue - - if not importlib.util.find_spec(package): - logger.info(f"Package {package} not found." - "Attempting to install...") - install_package(package) - - try: - __import__(package) - logger.info(f"Successfully imported {package}.") - installed_packages.add(package) - except ModuleNotFoundError: - logger.error(f"Failed to import {package} even " - "after installation.") - - save_installed_packages(installed_packages) - - -ensure_packages() - - -# Imports -try: - import garth - from garth.exc import GarthException, GarthHTTPError - from fit_tool.fit_file import FitFile - from fit_tool.fit_file_builder import FitFileBuilder - from fit_tool.profile.messages.file_creator_message import ( - FileCreatorMessage - ) - from fit_tool.profile.messages.record_message import ( - RecordMessage, - RecordTemperatureField - ) - from fit_tool.profile.messages.session_message import SessionMessage - from fit_tool.profile.messages.lap_message import LapMessage -except ImportError as e: - logger.error(f"Error importing modules: {e}") - - -TOKENS_PATH = SCRIPT_DIR / '.garth' -FILE_DIALOG_TITLE = "MyWhoosh2Garmin" -# Fix for https://github.com/JayQueue/MyWhoosh2Garmin/issues/2 -MYWHOOSH_PREFIX_WINDOWS = "MyWhooshTechnologyService." - - -def get_fitfile_location() -> Path: - """ - Get the location of the FIT file directory based on the operating system. - - Returns: - Path: The path to the FIT file directory. - - Raises: - RuntimeError: If the operating system is unsupported. - SystemExit: If the target path does not exist. - """ - if os.name == "posix": # macOS and Linux - target_path = ( - Path.home() - / "Library" - / "Containers" - / "com.whoosh.whooshgame" - / "Data" - / "Library" - / "Application Support" - / "Epic" - / "MyWhoosh" - / "Content" - / "Data" - ) - if target_path.is_dir(): - return target_path - else: - logger.error(f"Target path {target_path} does not exist. " - "Check your MyWhoosh installation.") - sys.exit(1) - elif os.name == "nt": # Windows - try: - base_path = Path.home() / "AppData" / "Local" / "Packages" - for directory in base_path.iterdir(): - if (directory.is_dir() and - directory.name.startswith(MYWHOOSH_PREFIX_WINDOWS)): - target_path = ( - directory - / "LocalCache" - / "Local" - / "MyWhoosh" - / "Content" - / "Data" - ) - if target_path.is_dir(): - return target_path - else: - raise FileNotFoundError(f"No valid MyWhoosh directory found in {target_path}") - except FileNotFoundError as e: - logger.error(str(e)) - except PermissionError as e: - logger.error(f"Permission denied: {e}") - except Exception as e: - logger.error(f"Unexpected error: {e}") - else: - logger.error("Unsupported OS") - return Path() - - -def get_backup_path(json_file=json_file_path) -> Path: - """ - This function checks if a backup path already exists in a JSON file. - If it does, it returns the stored path. If the file does not exist, - it prompts the user to select a directory via a file dialog, saves - the selected path to the JSON file, and returns it. - - Args: - json_file (str): Path to the JSON file containing the backup path. - - Returns: - str or None: The selected backup path or None if no path was selected. - """ - if os.path.exists(json_file): - with open(json_file, 'r') as f: - backup_path = json.load(f).get('backup_path') - if backup_path and os.path.isdir(backup_path): - logger.info(f"Using backup path from JSON: {backup_path}.") - return Path(backup_path) - else: - logger.error("Invalid backup path stored in JSON.") - sys.exit(1) - else: - root = tk.Tk() - root.withdraw() - backup_path = filedialog.askdirectory(title=f"Select {FILE_DIALOG_TITLE} " - "Directory") - if not backup_path: - logger.info("No directory selected, exiting.") - return Path() - with open(json_file, 'w') as f: - json.dump({'backup_path': backup_path}, f) - logger.info(f"Backup path saved to {json_file}.") - return Path(backup_path) - -FITFILE_LOCATION = get_fitfile_location() -BACKUP_FITFILE_LOCATION = get_backup_path() - -def get_credentials_for_garmin(): - """ - Prompt the user for Garmin credentials and authenticate using Garth. - - Returns: - None - - Exits: - Exits with status 1 if authentication fails. - """ - username = input("Username: ") - password = getpass("Password: ") - logger.info("Authenticating...") - try: - garth.login(username, password) - garth.save(TOKENS_PATH) - print() - logger.info("Successfully authenticated!") - except GarthHTTPError: - logger.info("Wrong credentials. Please check username and password.") - sys.exit(1) - - -def authenticate_to_garmin(): - """ - Authenticate the user to Garmin by checking for existing tokens and - resuming the session, or prompting for credentials if no session - exists or the session is expired. - - Returns: - None - - Exits: - Exits with status 1 if authentication fails. - """ - try: - if TOKENS_PATH.exists(): - garth.resume(TOKENS_PATH) - try: - logger.info(f"Authenticated as: {garth.client.username}") - except GarthException: - logger.info("Session expired. Re-authenticating...") - get_credentials_for_garmin() - else: - logger.info("No existing session. Please log in.") - get_credentials_for_garmin() - except GarthException as e: - logger.info(f"Authentication error: {e}") - sys.exit(1) - - -def calculate_avg(values: iter) -> int: - """ - Calculate the average of a list of values, returning 0 if the list is empty. - - Args: - values (List[float]): The list of values to average. - - Returns: - float: The average value or 0 if the list is empty. - """ - return sum(values) / len(values) if values else 0 - - -def append_value(values: List[int], message: object, field_name: str) -> None: - """ - Appends a value to the 'values' list based on a field from 'message'. - - Args: - values (List[int]): The list to append the value to. - message (object): The object that holds the field value. - field_name (str): The name of the field to retrieve from the message. - - Returns: - None - """ - value=getattr(message, field_name, None) - values.append(value if value else 0) - - -def reset_values() -> tuple[List[int], List[int], List[int]]: - """ - Resets and returns three empty lists for cadence, power - and heart rate values. - - Returns: - tuple: A tuple containing three empty lists - (cadence, power, and heart rate). - """ - return [], [], [] - - -def cleanup_fit_file(fit_file_path: Path, new_file_path: Path) -> None: - """ - Clean up the FIT file by processing and removing unnecessary fields. - Also, calculate average values for cadence, power, and heart rate. - - Args: - fit_file_path (Path): The path to the input FIT file. - new_file_path (Path): The path to save the processed FIT file. - - Returns: - None - """ - builder = FitFileBuilder() - fit_file = FitFile.from_file(str(fit_file_path)) - cadence_values, power_values, heart_rate_values = reset_values() - - for record in fit_file.records: - message = record.message - if isinstance(message, (LapMessage)): - continue - if isinstance(message, RecordMessage): - message.remove_field(RecordTemperatureField.ID) - append_value(cadence_values, message, "cadence") - append_value(power_values, message, "power") - append_value(heart_rate_values, message, "heart_rate") - if isinstance(message, SessionMessage): - if not message.avg_cadence: - message.avg_cadence = calculate_avg(cadence_values) - if not message.avg_power: - message.avg_power = calculate_avg(power_values) - if not message.avg_heart_rate: - message.avg_heart_rate = calculate_avg(heart_rate_values) - cadence_values, power_values, heart_rate_values = reset_values() - builder.add(message) - builder.build().to_file(str(new_file_path)) - logger.info(f"Cleaned-up file saved as {SCRIPT_DIR}/{new_file_path.name}") - - -def get_most_recent_fit_file(fitfile_location: Path) -> Path: - """ - Returns the most recent .fit file based - on versioning in the filename. - """ - fit_files = fitfile_location.glob("MyNewActivity-*.fit") - fit_files = sorted(fit_files, key=lambda f: - tuple(map(int, re.findall(r'(\d+)', - f.stem.split('-')[-1]))), - reverse=True) - return fit_files[0] if fit_files else Path() - - -def generate_new_filename(fit_file: Path) -> str: - """Generates a new filename with a timestamp.""" - timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S") - return f"{fit_file.stem}_{timestamp}.fit" - - -def cleanup_and_save_fit_file(fitfile_location: Path) -> Path: - """ - Clean up the most recent .fit file in a directory and save it - with a timestamped filename. - - Args: - fitfile_location (Path): The directory containing the .fit files. - - Returns: - Path: The path to the newly saved and cleaned .fit file, - or an empty Path if no .fit file is found or if the path is invalid. - """ - if not fitfile_location.is_dir(): - logger.info(f"The specified path is not a directory:" - f"{fitfile_location}.") - return Path() - - logger.debug(f"Checking for .fit files in directory: {fitfile_location}.") - fit_file = get_most_recent_fit_file(fitfile_location) - - if not fit_file: - logger.info("No .fit files found.") - return Path() - - logger.debug(f"Found the most recent .fit file: {fit_file.name}.") - new_filename = generate_new_filename(fit_file) - - if not BACKUP_FITFILE_LOCATION.exists(): - logger.error(f"{BACKUP_FITFILE_LOCATION} does not exist." - "Did you delete it?") - return Path() - - new_file_path = BACKUP_FITFILE_LOCATION / new_filename - logger.info(f"Cleaning up {new_file_path}.") - - try: - cleanup_fit_file(fit_file, new_file_path) - logger.info(f"Successfully cleaned {fit_file.name} " - f"and saved it as {new_file_path.name}.") - return new_file_path - except Exception as e: - logger.error(f"Failed to process {fit_file.name}: {e}.") - return Path() - - -def upload_fit_file_to_garmin(new_file_path: Path): - """ - Upload a .fit file to Garmin using the Garth client. - - Args: - new_file_path (Path): The path to the .fit file to upload. - - Returns: - None - """ - try: - if new_file_path and new_file_path.exists(): - with open(new_file_path, "rb") as f: - uploaded = garth.client.upload(f) - logger.debug(uploaded) - else: - logger.info(f"Invalid file path: {new_file_path}.") - except GarthHTTPError: - logger.info("Duplicate activity found on Garmin Connect.") - - def main(): - """ - Main function to authenticate to Garmin, clean and save the FIT file, - and upload it to Garmin. - - Returns: - None - """ authenticate_to_garmin() - new_file_path = cleanup_and_save_fit_file(FITFILE_LOCATION) - if new_file_path: - upload_fit_file_to_garmin(new_file_path) + client_builder = StravaClientBuilder() + client = client_builder.with_auth().with_cookies().build() + + strava_retrieved_activities = client.get_filtered_activities() + names, start_times = list_virtual_cycling_activities(last_n_days=7) + + def strip_timezone(dt): + if dt.tzinfo is not None: + return dt.replace(tzinfo=None) + return dt + + start_times_no_tz = {strip_timezone(dt) for dt in start_times} + + new_activities = [ + activity + for activity in strava_retrieved_activities + if strip_timezone(activity.start_date_local) not in start_times_no_tz + ] + logger.info( + f"Found {len(new_activities)} new virtual cycling activities to upload to Garmin." + ) + + for activity in new_activities: + client.downloader.download_activity(activity.id) + file_name = f"{activity.name}.json" + input_path = RAW_FIT_FILE_PATH / file_name + output_path = RAW_FIT_FILE_PATH.parent / "processed" / f"{activity.name}.fit" + builder = MyWhooshFitBuilder(input_path) + builder.build(output_path) if __name__ == "__main__": diff --git a/strava/main.py b/strava/client.py similarity index 74% rename from strava/main.py rename to strava/client.py index 1919d20..1e74798 100644 --- a/strava/main.py +++ b/strava/client.py @@ -5,22 +5,26 @@ """ import json +import logging import os import sqlite3 -import requests +import time from datetime import datetime, timedelta from pathlib import Path from typing import List, Optional from urllib.parse import parse_qs, urlparse +import requests from pydantic import BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict from requests import Session +logger = logging.getLogger(__name__) + class StravaSettings(BaseSettings): """Configuration settings for Strava API client.""" - + client_id: str = Field(..., validation_alias="CLIENT_ID") client_secret: str = Field(..., validation_alias="CLIENT_SECRET") token_url: str = "https://www.strava.com/oauth/token" @@ -30,12 +34,14 @@ class StravaSettings(BaseSettings): activities_url: str = "https://www.strava.com/api/v3/athlete/activities" database_file: str = "strava.db" - model_config = SettingsConfigDict(env_file=".env", extra="ignore") + model_config = SettingsConfigDict( + env_file=Path(__file__).parent.parent / ".env", extra="ignore" + ) # noqa: E501 class TokenData(BaseModel): """Model for storing Strava API token data.""" - + access_token: str refresh_token: str expires_at: datetime @@ -50,16 +56,19 @@ def from_json(cls, data: dict): class ActivityDetails(BaseModel): """Model representing Strava activity details.""" - + id: int name: str start_date: datetime + start_date_local: datetime + elapsed_time: int + distance: float type: str class ActivityDatabase: """Database handler for tracking downloaded activities.""" - + def __init__(self, db_file: str): self.conn = sqlite3.connect(db_file) self._create_table() @@ -78,8 +87,7 @@ def _create_table(self): def is_downloaded(self, activity_id: int) -> bool: """Check if activity is already downloaded.""" cursor = self.conn.execute( - "SELECT 1 FROM downloaded_activities WHERE activity_id = ?", - (activity_id,) + "SELECT 1 FROM downloaded_activities WHERE activity_id = ?", (activity_id,) ) return bool(cursor.fetchone()) @@ -87,7 +95,7 @@ def mark_downloaded(self, activity_id: int): """Mark an activity as downloaded.""" self.conn.execute( "INSERT OR IGNORE INTO downloaded_activities (activity_id) VALUES (?)", - (activity_id,) + (activity_id,), ) self.conn.commit() @@ -98,7 +106,7 @@ def close(self): class StravaAuth: """Handles Strava OAuth2 authentication and token management.""" - + def __init__(self, settings: StravaSettings): self.settings = settings self.token_data: Optional[TokenData] = None @@ -108,9 +116,9 @@ def __init__(self, settings: StravaSettings): def _initialize_session(self): """Initialize requests session with valid token.""" if self._load_tokens() and self._is_token_valid(): - self.session.headers.update({ - "Authorization": f"Bearer {self.token_data.access_token}" - }) + self.session.headers.update( + {"Authorization": f"Bearer {self.token_data.access_token}"} + ) def _is_token_valid(self) -> bool: """Check if access token is still valid.""" @@ -132,7 +140,7 @@ def authenticate(self) -> None: self.refresh_token() except requests.HTTPError as e: if e.response.status_code == 400: - print("Refresh token expired, re-authenticating...") + logger.warning("Refresh token expired, re-authenticating...") self._perform_oauth_flow() else: raise @@ -206,7 +214,7 @@ def refresh_token(self) -> None: class CookieManager: """Manages HTTP cookies for persistent session.""" - + def __init__(self, cookie_file: str): self.cookie_file = cookie_file self.session = Session() @@ -222,13 +230,13 @@ def load_cookies(self) -> None: class ActivityDownloader: """Handles activity file downloads with Chrome-like headers.""" - + CHROME_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/119.0.0.0 Safari/537.36", + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/119.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9," - "image/avif,image/webp,image/apng,*/*;q=0.8", + "image/avif,image/webp,image/apng,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive", @@ -236,7 +244,7 @@ class ActivityDownloader: "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "same-origin", "Sec-Fetch-User": "?1", - "Upgrade-Insecure-Requests": "1" + "Upgrade-Insecure-Requests": "1", } def __init__(self, session: Session, database: ActivityDatabase): @@ -249,36 +257,80 @@ def download_activity(self, activity_id: int) -> bool: return self._download_attempt(activity_id) except requests.HTTPError as e: if e.response.status_code == 401: - print("Token expired during download, refreshing...") + logger.warning("Token expired during download, refreshing...") self.session.auth.refresh_token() return self._download_attempt(activity_id) raise def _download_attempt(self, activity_id: int) -> bool: - """Perform single download attempt for an activity.""" + """Perform single download attempt with metadata + streams.""" if self.db.is_downloaded(activity_id): return False - response = self.session.get( - f"https://www.strava.com/activities/{activity_id}/export_original", - stream=True, - headers=self.CHROME_HEADERS + # 1. Fetch activity metadata + activity_response = self.session.get( + f"https://www.strava.com/api/v3/activities/{activity_id}", + params={"include_all_efforts": "true"}, + headers=self.CHROME_HEADERS, ) - response.raise_for_status() + activity_response.raise_for_status() + activity_data = activity_response.json() + + activity_name = activity_data.get("name", f"activity_{activity_id}") + # Parse and trim start_date to only date part (YYYY-MM-DD) + start_date_str = activity_data.get("start_date", "unknown_date") + start_date = ( + start_date_str.split("T")[0] if "T" in start_date_str else start_date_str + ) + + # 2. Fetch time-series streams + stream_types = [ + "time", + "latlng", + "distance", + "altitude", + "velocity_smooth", + "heartrate", + "cadence", + "watts", + "temp", + "moving", + "grade_smooth", + ] + + streams_response = self.session.get( + f"https://www.strava.com/api/v3/activities/{activity_id}/streams", + params={ + "keys": ",".join(stream_types), + "key_by_type": "true", + }, + headers=self.CHROME_HEADERS, + ) + streams_response.raise_for_status() + streams_data = streams_response.json() - filename = f"activity_{activity_id}_original.fit" - with open(filename, "wb") as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) + # 3. Combine both datasets + combined_data = { + "metadata": activity_data, + "streams": streams_data, + } + + # 4. Save combined JSON + data_dir = Path(__file__).parent.parent / "data" / "raw" + data_dir.mkdir(parents=True, exist_ok=True) + + json_filename = data_dir / f"{activity_name}.json" + with open(json_filename, "w") as f: + json.dump(combined_data, f, indent=2, default=str) self.db.mark_downloaded(activity_id) - print(f"βœ… Downloaded {filename}") + logger.info(f"βœ… Downloaded {json_filename}") return True class StravaClient: """Main client for interacting with Strava API.""" - + def __init__(self, auth: StravaAuth, downloader: ActivityDownloader): self.auth = auth self.downloader = downloader @@ -289,14 +341,13 @@ def get_filtered_activities(self) -> List[ActivityDetails]: try: response = self.auth.session.get( - self.auth.settings.activities_url, - params={"per_page": 100} + self.auth.settings.activities_url, params={"per_page": 10} ) response.raise_for_status() except requests.HTTPError as e: if e.response.status_code == 401: - print("Token expired during request, refreshing...") + logger.warning("Token expired during request, refreshing...") self.auth.refresh_token() return self.get_filtered_activities() raise @@ -311,7 +362,7 @@ def get_filtered_activities(self) -> List[ActivityDetails]: class StravaClientBuilder: """Builder pattern implementation for StravaClient.""" - + def __init__(self): self.settings = StravaSettings() self.auth = StravaAuth(self.settings) @@ -330,10 +381,7 @@ def with_cookies(self) -> "StravaClientBuilder": def build(self) -> StravaClient: """Build configured StravaClient instance.""" - downloader = ActivityDownloader( - self.auth.session, - self.database - ) + downloader = ActivityDownloader(self.auth.session, self.database) return StravaClient(self.auth, downloader) def __del__(self): @@ -342,6 +390,7 @@ def __del__(self): if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) client_builder = None try: client_builder = StravaClientBuilder() @@ -349,31 +398,32 @@ def __del__(self): all_activities = client.get_filtered_activities() new_activities = [ - a for a in all_activities - if not client.downloader.db.is_downloaded(a.id) + a for a in all_activities if not client.downloader.db.is_downloaded(a.id) ] if not new_activities: - print("No new activities found") + logger.warning("No new activities found") exit() - print("\nπŸ† New Virtual Rides with 'MyWhoosh' in name:") + logger.info("\nπŸ† New Virtual Rides with 'MyWhoosh' in name:") for activity in new_activities: date_str = activity.start_date.strftime("%Y-%m-%d %H:%M") - print(f"πŸ“… {date_str} - {activity.name} (ID: {activity.id})") + logger.info(f"πŸ“… {date_str} - {activity.name} (ID: {activity.id})") + + time.sleep(5) # Small delay before downloads new_downloads = 0 for activity in new_activities: if client.downloader.download_activity(activity.id): new_downloads += 1 - print("\nDownload summary:") - print(f"β€’ New activities downloaded: {new_downloads}") - print(f"β€’ Already existed: {len(all_activities) - len(new_activities)}") - print(f"β€’ Total processed: {len(all_activities)}") + logger.info("\nDownload summary:") + logger.info(f"β€’ New activities downloaded: {new_downloads}") + logger.info(f"β€’ Already existed: {len(all_activities) - len(new_activities)}") + logger.info(f"β€’ Total processed: {len(all_activities)}") except Exception as e: - print(f"❌ Error: {str(e)}") + logger.error(f"❌ Error: {str(e)}") finally: if client_builder: client_builder.database.close() From abb0ab4deeb1300ad7d909f0689bbfbd53b274a5 Mon Sep 17 00:00:00 2001 From: Marc <128506536+MarcChen@users.noreply.github.com> Date: Thu, 13 Nov 2025 22:37:32 +0100 Subject: [PATCH 4/9] chore: align pre-commit and ruff versions in configuration files --- .pre-commit-config.yaml | 4 ++-- pyproject.toml | 4 ++-- uv.lock | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 12e5c2a..0daa41e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,14 +2,14 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v4.4.0 hooks: - id: trailing-whitespace - id: check-json - id: check-toml - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.3 + rev: v0.14.5 hooks: # Run the linter - id: ruff diff --git a/pyproject.toml b/pyproject.toml index 1cd32eb..b6538a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,8 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pre-commit>=3.5.0", - "ruff>=0.14.3", + "pre-commit==4.4.0", + "ruff==0.14.5", ] [build-system] diff --git a/uv.lock b/uv.lock index 103085d..63e8894 100644 --- a/uv.lock +++ b/uv.lock @@ -208,10 +208,10 @@ requires-dist = [ { name = "fit-tool", specifier = "==0.9.13" }, { name = "garth", specifier = "==0.5.2" }, { name = "playwright", specifier = "==1.55.0" }, - { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.4.0" }, { name = "pydantic", specifier = "==2.12.4" }, { name = "pydantic-settings", specifier = "==2.11.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.3" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.5" }, ] provides-extras = ["dev"] From f783f84599eaf31ad694df58558c1ca7ad163672 Mon Sep 17 00:00:00 2001 From: Marc <128506536+MarcChen@users.noreply.github.com> Date: Sat, 15 Nov 2025 14:23:34 +0100 Subject: [PATCH 5/9] feat: enhance Garmin and Strava settings with improved token management and environment variable support --- garmin/utils.py | 48 +++++++++++++++++++++++++++++++++++------- strava/client.py | 54 ++++++++++++++++++++++++++++++++++++------------ 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/garmin/utils.py b/garmin/utils.py index 126dba7..57228a5 100644 --- a/garmin/utils.py +++ b/garmin/utils.py @@ -1,4 +1,5 @@ import logging +import os import sys from datetime import datetime, timedelta from pathlib import Path @@ -6,7 +7,7 @@ import garth from garth.exc import GarthException, GarthHTTPError -from pydantic import Field +from pydantic import Field, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict logger = logging.getLogger(__name__) @@ -15,11 +16,24 @@ class GarminSettings(BaseSettings): """Configuration settings for Garmin API client.""" - garmin_username: str = Field(..., validation_alias="GARMIN_USERNAME") - garmin_password: str = Field(..., validation_alias="GARMIN_PASSWORD") + garmin_username: str = Field( + default=None, + alias="GARMIN_USERNAME", + description="Garmin Connect username only used to authenticate and retrieve tokens.", # noqa: E501 + ) + garmin_password: SecretStr = Field( + default=None, + alias="GARMIN_PASSWORD", + description="Garmin Connect password used for authentication.", + ) garmin_tokens_path: Path = Field( default=Path(__file__).parent.parent / ".garth", ) + garmin_tokens: str = Field( + default=None, + alias="GARMIN_TOKENS", + description="Garmin Connect tokens from garth client.dumps() for authentication.", # noqa: E501 + ) model_config = SettingsConfigDict( env_file=Path(__file__).parent.parent / ".env", extra="ignore" @@ -38,16 +52,25 @@ def get_credentials_for_garmin(garmin_settings: GarminSettings = GarminSettings( """ logger.info("Authenticating...") try: + # First try to restore from environment variable + token_env = os.getenv("GARMIN_TOKENS") + if token_env: + garth.client.loads(token_env) + logger.info("Authenticated using GARMIN_TOKENS environment variable.") + return + # Fallback to username/password login garth.login( garmin_settings.garmin_username, - garmin_settings.garmin_password, + garmin_settings.garmin_password.get_secret_value(), prompt_mfa=lambda: input("Enter MFA code: "), ) garth.save(garmin_settings.garmin_tokens_path) print() logger.info("Successfully authenticated!") except GarthHTTPError: - logger.info("Wrong credentials. Please check username and password.") + logger.info( + "Wrong credentials or authentication failed. Please check username and password." # noqa: E501 + ) sys.exit(1) @@ -103,7 +126,7 @@ def upload_fit_file_to_garmin(new_file_path: Path): def list_virtual_cycling_activities( last_n_days: int = 30, ) -> Tuple[List[str], List[datetime]]: - """Return two lists: activity names and start times of virtual cycling activities from Garmin Connect.""" + """Return two lists: activity names and start times of virtual cycling activities from Garmin Connect.""" # noqa: E501 logger.info( f"Retrieving virtual cycling activities from the last {last_n_days} days..." ) @@ -127,13 +150,22 @@ def list_virtual_cycling_activities( start_time = None start_times.append(start_time) logger.debug( - f"Found virtual cycling activity: {activity['activityName']} at {activity.get('startTimeLocal', '')} with elapsed time {activity.get('elapsedTime', '')}." + f"Found virtual cycling activity: {activity['activityName']} at {activity.get('startTimeLocal', '')} with elapsed time {activity.get('elapsedTime', '')}." # noqa: E501 ) return names, start_times +def dump_token_string_as_vars(): + """Utility function to dump Garmin tokens as environment variable strings.""" + token_string = garth.client.dumps() + logger.info("Garmin token string (CAREFUL: save it securely!") + logger.info(token_string) + + if __name__ == "__main__": logging.basicConfig(level=logging.INFO) authenticate_to_garmin() # Example usage: - list_virtual_cycling_activities() + names, start_times = list_virtual_cycling_activities() + for name, start_time in zip(names, start_times): + print(f"Activity: {name}, Start Time: {start_time}") diff --git a/strava/client.py b/strava/client.py index 1e74798..b40d887 100644 --- a/strava/client.py +++ b/strava/client.py @@ -11,11 +11,11 @@ import time from datetime import datetime, timedelta from pathlib import Path -from typing import List, Optional +from typing import Any, List, Optional from urllib.parse import parse_qs, urlparse import requests -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict from requests import Session @@ -25,19 +25,49 @@ class StravaSettings(BaseSettings): """Configuration settings for Strava API client.""" - client_id: str = Field(..., validation_alias="CLIENT_ID") - client_secret: str = Field(..., validation_alias="CLIENT_SECRET") + client_id: str | None = Field( + default=None, + description="Strava API Client ID necessary to generate access tokens.", + ) + client_secret: SecretStr | None = Field( + default=None, + description="Strava API Client Secret necessary to generate access tokens.", + ) + token_type: str = Field( + default="Bearer", + ) + access_token: SecretStr + expires_at: int + expires_in: int + refresh_token: SecretStr token_url: str = "https://www.strava.com/oauth/token" auth_base_url: str = "https://www.strava.com/oauth/authorize" - token_file: str = "strava_tokens.json" - cookie_file: str = "cookie.json" + token_file: Path = Path(__file__).parent.parent / "strava_tokens.json" + cookie_file: Path = Path(__file__).parent.parent / "cookie.json" activities_url: str = "https://www.strava.com/api/v3/athlete/activities" - database_file: str = "strava.db" + database_file: Path = Path(__file__).parent.parent / "strava.db" model_config = SettingsConfigDict( - env_file=Path(__file__).parent.parent / ".env", extra="ignore" + env_file=Path(__file__).parent.parent / ".env", + extra="ignore", + env_prefix="STRAVA_", ) # noqa: E501 + def model_post_init(self, __context: Any) -> None: + """Create necessary files if they don't exist.""" + # Create token file if it doesn't exist + token_path = self.token_file + # Only dump selected fields to token file if it doesn't exist + if not token_path.exists(): + token_data = { + "token_type": self.token_type, + "access_token": str(self.access_token.get_secret_value()), + "expires_at": self.expires_at, + "expires_in": self.expires_in, + "refresh_token": str(self.refresh_token.get_secret_value()), + } + token_path.write_text(json.dumps(token_data)) + class TokenData(BaseModel): """Model for storing Strava API token data.""" @@ -170,7 +200,7 @@ def _fetch_token(self, redirect_url: str) -> None: self.settings.token_url, data={ "client_id": self.settings.client_id, - "client_secret": self.settings.client_secret, + "client_secret": str(self.settings.client_secret.get_secret_value()), "code": code[0], "grant_type": "authorization_code", }, @@ -203,7 +233,7 @@ def refresh_token(self) -> None: self.settings.token_url, data={ "client_id": self.settings.client_id, - "client_secret": self.settings.client_secret, + "client_secret": str(self.settings.client_secret.get_secret_value()), "grant_type": "refresh_token", "refresh_token": self.token_data.refresh_token, }, @@ -279,9 +309,7 @@ def _download_attempt(self, activity_id: int) -> bool: activity_name = activity_data.get("name", f"activity_{activity_id}") # Parse and trim start_date to only date part (YYYY-MM-DD) start_date_str = activity_data.get("start_date", "unknown_date") - start_date = ( - start_date_str.split("T")[0] if "T" in start_date_str else start_date_str - ) + _ = start_date_str.split("T")[0] if "T" in start_date_str else start_date_str # 2. Fetch time-series streams stream_types = [ From fa62277f8cef387fb323704da5e7c14b6efb95e2 Mon Sep 17 00:00:00 2001 From: Marc <128506536+MarcChen@users.noreply.github.com> Date: Sat, 15 Nov 2025 14:23:57 +0100 Subject: [PATCH 6/9] feat: add github action usage --- .env-temmplate | 9 ++++++++ .gitignore | 7 ++++++- terraform/README.md | 12 +++++++++++ terraform/main.tf | 36 ++++++++++++++++++++++++++++++++ terraform/variables.tf | 47 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 .env-temmplate create mode 100644 terraform/README.md create mode 100644 terraform/main.tf create mode 100644 terraform/variables.tf diff --git a/.env-temmplate b/.env-temmplate new file mode 100644 index 0000000..3511d59 --- /dev/null +++ b/.env-temmplate @@ -0,0 +1,9 @@ +# .env-template + +STRAVA_CLIENT_ID="12345" +STRAVA_CLIENT_SECRET="" +STRAVA_ACCESS_TOKEN="" +STRAVA_EXPIRES_AT="" +STRAVA_EXPIRES_IN="" +STRAVA_REFRESH_TOKEN="" +GARMIN_TOKENS="" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 98d743b..bcf31b1 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,9 @@ Thumbs.db data/ .env **token** -strava.db \ No newline at end of file +strava.db + +.terraform/ +.terraform.lock.hcl +terraform.tfstate +terraform.tfstate.backup \ No newline at end of file diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..faabd33 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,12 @@ +```bash +terraform plan \ + -var="github_owner=${GITHUB_OWNER:-MarcChen}" \ + -var="github_repository=${GITHUB_REPOSITORY:-MyWhoosh2Garmin}" \ + -var="strava_client_id=${STRAVA_CLIENT_ID}" \ + -var="strava_client_secret=${STRAVA_CLIENT_SECRET}" \ + -var="strava_access_token=${STRAVA_ACCESS_TOKEN}" \ + -var="strava_refresh_token=${STRAVA_REFRESH_TOKEN}" \ + -var="strava_expires_at=${STRAVA_EXPIRES_AT}" \ + -var="strava_expires_in=${STRAVA_EXPIRES_IN}" \ + -var="garmin_tokens=${GARMIN_TOKENS}" +``` \ No newline at end of file diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..4a74ddc --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,36 @@ +terraform { + required_providers { + github = { + source = "integrations/github" + version = "~> 5.0" + } + } +} + +provider "github" { + # The provider will use the GITHUB_TOKEN environment variable by default. + # If you prefer, pass a token with `-var="github_token=..."` or set `TF_VAR_github_token`. + owner = var.github_owner +} + +locals { + raw_secrets = { + STRAVA_CLIENT_ID = var.strava_client_id + STRAVA_CLIENT_SECRET = var.strava_client_secret + STRAVA_ACCESS_TOKEN = var.strava_access_token + STRAVA_EXPIRES_AT = var.strava_expires_at + STRAVA_EXPIRES_IN = var.strava_expires_in + STRAVA_REFRESH_TOKEN = var.strava_refresh_token + GARMIN_TOKENS = var.garmin_tokens + } + + # remove empty values so we don't create blank secrets + secrets = { for k, v in local.raw_secrets : k => v if v != "" } +} + +resource "github_actions_secret" "repo_secrets" { + for_each = local.secrets + repository = var.github_repository + secret_name = each.key + plaintext_value = each.value +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..e8063f4 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,47 @@ +variable "github_owner" { + description = "The GitHub owner (user or organization) that owns the repository." + type = string + default = "MarcChen" +} + +variable "github_repository" { + description = "The repository name where secrets will be created." + type = string + default = "MyWhoosh2Garmin" +} + + +variable "strava_client_id" { + description = "STRAVA_CLIENT_ID from .env-temmplate" + type = string +} + +variable "strava_client_secret" { + description = "STRAVA_CLIENT_SECRET from .env-temmplate" + type = string +} + +variable "strava_access_token" { + description = "STRAVA_ACCESS_TOKEN from .env-temmplate" + type = string +} + +variable "strava_expires_at" { + description = "STRAVA_EXPIRES_AT from .env-temmplate" + type = string +} + +variable "strava_expires_in" { + description = "STRAVA_EXPIRES_IN from .env-temmplate" + type = string +} + +variable "strava_refresh_token" { + description = "STRAVA_REFRESH_TOKEN from .env-temmplate" + type = string +} + +variable "garmin_tokens" { + description = "GARMIN_TOKENS from .env-temmplate" + type = string +} From 3f4e28595638034385ec172cd2eb3be9a13d18f6 Mon Sep 17 00:00:00 2001 From: Marc <128506536+MarcChen@users.noreply.github.com> Date: Sun, 23 Nov 2025 16:17:58 +0100 Subject: [PATCH 7/9] feat: Implement automated Strava-to-Garmin sync, add dedicated authentication setup scripts, and restructure documentation. --- README.md | 329 +++++++++++++++++++++++-------------------- SETUP.md | 186 ++++++++++++++++++++++++ garmin/utils.py | 2 +- setup_garmin_auth.py | 63 +++++++++ setup_strava_auth.py | 128 +++++++++++++++++ strava/README.md | 12 -- strava/client.py | 26 ++-- 7 files changed, 571 insertions(+), 175 deletions(-) create mode 100644 SETUP.md create mode 100644 setup_garmin_auth.py create mode 100644 setup_strava_auth.py delete mode 100644 strava/README.md diff --git a/README.md b/README.md index b22afe4..91f544f 100644 --- a/README.md +++ b/README.md @@ -1,203 +1,228 @@ -

myWhoosh2Garmin

+# MyWhoosh2Garmin -

🧐Features

+> **Automated Strava-to-Garmin Sync for MyWhoosh Activities** +> +> Zero manual steps. Just ride, and your training effect appears on Garmin Connect automatically. -* Finds the .fit files from your MyWhoosh installation. -* Fix the missing power & heart rate averages. -* Removes the temperature. -* Create a backup file to a folder you select. -* Uploads the fixed .fit file to Garmin Connect. +## 🎯 What This Does -

πŸ› οΈ Installation Steps:

+This project automatically syncs your MyWhoosh virtual cycling activities from Strava to Garmin Connect as `.fit` files, ensuring they're recognized as Garmin device uploads. This is crucial for having your **training effect** properly reflected in your **Garmin Training Readiness** and **Preparation Score**. -

1. Download myWhoosh2Garmin.py to your filesystem to a folder or your choosing.

+### The Problem This Solves -

2. Go to the folder where you downloaded the script in a shell.

+The original [forked project](https://github.com/OriginalRepo/MyWhoosh2Garmin) required multiple manual steps: +- πŸ“ Manually downloading `.fit` files from MyWhoosh website +- πŸ”§ Applying transformations to fix power/heart rate averages +- 🌑️ Removing temperature data +- πŸ“€ Manually uploading to Garmin Connect -- MacOS: Terminal of your choice. -- Windows: Start > Run > cmd or Start > Run > powershell +**This was tedious and error-prone.** Big training sessions weren't impacting training preparation scores because manual uploads were missed. -

3. Install `pipenv` (if not already installed):

+### The Solution -``` -pip3 install pipenv -or -pip install pipenv -``` -

4. Install dependencies in a virtual envioronment:

+This fully automated workflow: +- βœ… **Zero manual steps** β€” runs completely in the cloud via GitHub Actions +- βœ… **Automatic detection** β€” finds new MyWhoosh activities on Strava +- βœ… **Smart filtering** β€” only uploads activities not already on Garmin Connect +- βœ… **Training effect preserved** β€” `.fit` files recognized as Garmin device uploads +- βœ… **Webhook support** β€” optional instant sync when you upload to Strava -``` -pipenv install +## πŸ—οΈ Architecture + +```mermaid +graph LR + A[MyWhoosh App] -->|Auto-upload| B[Strava] + B -->|Webhook trigger| C{GitHub Actions} + C -->|If Created & Virtual Ride: Fetch activity via API| D[Strava Client] + D -->|Download .fit data| E[FIT Builder] + E -->|Convert to Garmin .fit| F[Garmin Client] + F -->|Upload as device| G[Garmin Connect] ``` -

5. Activate the virtual environment:

+### Key Components Reused -``` -pipenv shell -``` +- **Garmin Client** ([garth](https://github.com/matin/garth)) β€” handles authentication and upload +- **FIT Builder** ([fit_tool](https://bitbucket.org/stagescycling/fit_tool/src/main/)) β€” converts Strava JSON to Garmin-compatible `.fit` +- **Strava API** β€” OAuth2 authentication and activity download -

5. Run the script:

+## πŸš€ Quick Start -``` -python3 myWhoosh2Garmin.py -or -python myWhoosh2Garmin.py -``` - -

6. Choose your backup folder.

+> **πŸ“– For detailed step-by-step instructions, see [SETUP.md](SETUP.md)** -

MacOS

+### Prerequisites -![image](https://github.com/user-attachments/assets/2c6c1072-bacf-4f0c-8861-78f62bf51648) +- **GitHub account** (for running GitHub Actions) +- **Strava account** with MyWhoosh activities auto-uploaded +- **Garmin Connect account** +- **Strava API application** ([create one here](https://www.strava.com/settings/api)) +### 1️⃣ Repository Setup -

Windows

+1. **Fork this repository** or clone it to your GitHub account +2. **Enable GitHub Actions** in your repository settings +### 2️⃣ Authentication Setup -![image](https://github.com/user-attachments/assets/d1540291-4e6d-488e-9dcf-8d7b68651103) +#### Garmin Authentication -

7. Enter your Garmin Connect credentials

+Run the Garmin setup script locally to authenticate and generate tokens: -``` -2024-11-21 10:08:04,014 No existing session. Please log in. -Username: -Password: -2024-11-21 10:08:33,545 Authenticating... +```bash +# Clone the repo +git clone https://github.com/YourUsername/MyWhoosh2Garmin.git +cd MyWhoosh2Garmin -2024-11-21 10:08:37,107 Successfully authenticated! +# Install dependencies +pip install uv +uv venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate +uv pip install -r pyproject.toml + +# Run Garmin authentication +python garmin/utils.py ``` -

8. Run the script when you're done riding or running.

+You'll be prompted for: +- Garmin username (email) +- Garmin password +- **2FA code** (if enabled) -``` -2024-11-21 10:08:37,107 Checking for .fit files in directory: . -2024-11-21 10:08:37,107 Found the most recent .fit file: MyNewActivity-3.8.5.fit. -2024-11-21 10:08:37,107 Cleaning up yNewActivity-3.8.5_2024-11-21_100837.fit. -2024-11-21 10:08:37,855 Cleaned-up file saved as MyNewActivity-3.8.5_2024-11-21_100837.fit -2024-11-21 10:08:37,871 Successfully cleaned MyNewActivity-3.8.5.fit and saved it as MyNewActivity-3.8.5_2024-11-21_100837.fit. -2024-11-21 10:08:38,408 Duplicate activity found on Garmin Connect. +The script will output a `GARMIN_TOKENS` string β€” **save this securely!** + +#### Strava Authentication + +Run the Strava setup script to initialize OAuth tokens: + +```bash +python strava/client.py ``` -

(9. Or see below to automate the process)

+Follow the prompts: +1. Click the authorization URL that appears +2. Authorize the application +3. Copy the callback URL from your browser +4. Paste it back into the terminal -

ℹ️ Automation tips

+This creates `strava_tokens.json` with your access/refresh tokens. -What if you want to automate the whole process: -

MacOS

+### 3️⃣ Configure GitHub Secrets -PowerShell on MacOS (Verified & works) +Go to your repository β†’ **Settings** β†’ **Secrets and variables** β†’ **Actions** β†’ **New repository secret** -You need Powershell +Add the following secrets: -```shell -brew install powershell/tap/powershell -``` +| Secret Name | Description | Where to Find | +|-------------|-------------|---------------| +| `STRAVA_CLIENT_ID` | Your Strava API application ID | [Strava API Settings](https://www.strava.com/settings/api) | +| `STRAVA_CLIENT_SECRET` | Your Strava API secret | [Strava API Settings](https://www.strava.com/settings/api) | +| `STRAVA_ACCESS_TOKEN` | OAuth access token | From `strava_tokens.json` after setup | +| `STRAVA_EXPIRES_AT` | Token expiration timestamp | From `strava_tokens.json` | +| `STRAVA_EXPIRES_IN` | Token expiration duration | From `strava_tokens.json` | +| `STRAVA_REFRESH_TOKEN` | OAuth refresh token | From `strava_tokens.json` | +| `GARMIN_TOKENS` | Garmin authentication tokens | From `garmin/utils.py` output | -```powershell -# Define the JSON config file path -$configFile = "$PSScriptRoot\mywhoosh_config.json" -$myWhooshApp = "myWhoosh Indoor Cycling App.app" - -# Check if the JSON file exists and read the stored path -if (Test-Path $configFile) { - $config = Get-Content -Path $configFile | ConvertFrom-Json - $mywhooshPath = $config.path -} else { - $mywhooshPath = $null -} - -# Validate the stored path -if (-not $mywhooshPath -or -not (Test-Path $mywhooshPath)) { - Write-Host "Searching for $myWhooshApp" - $mywhooshPath = Get-ChildItem -Path "/Applications" -Filter $myWhooshApp -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - - if (-not $mywhooshPath) { - Write-Host " not found!" - exit 1 - } - - $mywhooshPath = $mywhooshPath.FullName - - # Store the path in the JSON file - $config = @{ path = $mywhooshPath } - $config | ConvertTo-Json | Set-Content -Path $configFile -} - -Write-Host "Found $myWhooshApp at $mywhooshPath" - -Start-Process -FilePath $mywhooshPath - -# Wait for the application to finish -Write-Host "Waiting for $myWhooshApp to finish..." -while ($process = ps -ax | grep -i $myWhooshApp | grep -v "grep") { - Write-Output $process - Start-Sleep -Seconds 5 -} - -# Run the Python script -Write-Host "$myWhooshApp has finished, running Python script..." -python3 "/MyWhoosh2Garmin/myWhoosh2Garmin.py" -``` +### 4️⃣ Run the Workflow + +#### Manual Trigger -AppleScript (need to test further) +Go to **Actions** β†’ **Self-hosted runner β€” Run MyWhoosh2Garmin** β†’ **Run workflow** -```applescript -TODO: needs more work +That's it! The workflow will: +1. Fetch your last 7 days of Garmin virtual cycling activities +2. Check Strava for MyWhoosh activities +3. Download new activities not on Garmin +4. Convert to `.fit` format +5. Upload to Garmin Connect + +#### Automatic Webhook Trigger (Optional) + +For **instant sync** after every Strava upload, set up a webhook using my [WebhookProcessor](https://github.com/MarcChen/WebhookProcessor): + +1. Deploy the WebhookProcessor to handle Strava webhook events +2. Configure it to trigger your GitHub Actions workflow +3. Register the webhook URL with Strava + +Now every MyWhoosh activity uploaded to Strava will automatically sync to Garmin within minutes! ⚑ + +## πŸ“‹ Environment Variables + +The following environment variables are required for the complete workflow: + +```bash +# Strava OAuth credentials +STRAVA_CLIENT_ID="12345" +STRAVA_CLIENT_SECRET="your_strava_client_secret" +STRAVA_ACCESS_TOKEN="your_strava_access_token" +STRAVA_EXPIRES_AT="1234567890" +STRAVA_EXPIRES_IN="21600" +STRAVA_REFRESH_TOKEN="your_strava_refresh_token" + +# Garmin authentication (from garth client) +GARMIN_TOKENS="your_garmin_tokens_string" ``` -

Windows

+> **Note:** For local development, copy `.env-template` to `.env` and fill in your values. -Windows .ps1 (PowerShell) file (Untested on Windows) -```powershell -# Define the JSON config file path -$configFile = "$PSScriptRoot\mywhoosh_config.json" +## πŸ› οΈ How It Works -# Check if the JSON file exists and read the stored path -if (Test-Path $configFile) { - $config = Get-Content -Path $configFile | ConvertFrom-Json - $mywhooshPath = $config.path -} else { - $mywhooshPath = $null -} +### Workflow Steps -# Validate the stored path -if (-not $mywhooshPath -or -not (Test-Path $mywhooshPath)) { - Write-Host "Searching for mywhoosh.exe..." - $mywhooshPath = Get-ChildItem -Path "C:\PROGRAM FILES" -Filter "mywhoosh.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 +1. **Authentication** + - Garmin: Uses pre-authenticated tokens from `GARMIN_TOKENS` + - Strava: Uses OAuth2 with automatic token refresh - if (-not $mywhooshPath) { - Write-Host "mywhoosh.exe not found!" - exit 1 - } +2. **Activity Discovery** + - Fetches last 7 days of virtual cycling activities from Garmin Connect + - Fetches recent MyWhoosh activities from Strava (filtered by name containing "MyWhoosh") - $mywhooshPath = $mywhooshPath.FullName +3. **Smart Filtering** + - Compares Strava and Garmin activities by start time + - Only processes activities not already on Garmin - # Store the path in the JSON file - $config = @{ path = $mywhooshPath } - $config | ConvertTo-Json | Set-Content -Path $configFile -} +4. **Data Conversion** + - Downloads Strava activity data (metadata + streams: power, heart rate, cadence, etc.) + - Builds Garmin-compatible `.fit` file using `fit_tool` -Write-Host "Found mywhoosh.exe at $mywhooshPath" +5. **Upload** + - Uploads `.fit` file to Garmin Connect using `garth` client + - File is recognized as a Garmin device upload (triggers training effect) -# Start mywhoosh.exe -Start-Process -FilePath $mywhooshPath +### Database Tracking -# Wait for the application to finish -Write-Host "Waiting for mywhoosh to finish..." -while (Get-Process -Name "mywhoosh" -ErrorAction SilentlyContinue) { - Start-Sleep -Seconds 5 -} +The workflow uses SQLite (`strava.db`) to track downloaded activities and prevent duplicate processing. -# Run the Python script -Write-Host "mywhoosh has finished, running Python script..." -python "C:\Path\to\myWhoosh2Garmin.py" -``` +## πŸ”’ Security Notes + +- ⚠️ **Never commit** `.env` or token files to version control +- πŸ” Store all credentials as **GitHub Secrets** +- πŸ”„ Tokens are automatically refreshed when expired +- πŸ—‘οΈ Processed `.fit` files are deleted after upload + +## πŸ“¦ Dependencies + +- **Python 3.13+** +- **uv** (package manager) +- Key libraries: + - `garth` β€” Garmin Connect API client + - `pydantic` / `pydantic-settings` β€” configuration management + - `requests` β€” HTTP client + - `fit_tool` β€” FIT file manipulation + +## 🀝 Contributing + +Feel free to open issues or pull requests! This project is a personal automation tool, but improvements are welcome. + +## πŸ“„ License + +[GPL-3.0 License](LICENSE) + +## πŸ™ Acknowledgments -

πŸ’» Built with

+- Original inspiration from [MyWhoosh2Garmin](https://github.com/OriginalRepo/MyWhoosh2Garmin) +- [matin/garth](https://github.com/matin/garth) for excellent Garmin API client +- [fit_tool](https://bitbucket.org/stagescycling/fit_tool/src/main/) for FIT file utilities +- Strava API for activity data access -Technologies used in the project: +--- -* Neovim -* Garth -* tKinter -* Fit\_tool +**Made with ❀️ for cyclists who want their training data to just work.** diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..e716ff8 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,186 @@ +# πŸ”§ Setup Guide + +This guide walks you through setting up the MyWhoosh2Garmin automation from scratch. + +## Prerequisites + +Before you begin, make sure you have: + +- βœ… A GitHub account (for running GitHub Actions) +- βœ… A Strava account with MyWhoosh auto-upload enabled +- βœ… A Garmin Connect account +- βœ… Python 3.13+ installed locally (for initial setup only) + +## Step 1: Fork the Repository + +1. Click the **Fork** button at the top of this repository +2. Clone your fork to your local machine: + ```bash + git clone https://github.com/YourUsername/MyWhoosh2Garmin.git + cd MyWhoosh2Garmin + ``` + +## Step 2: Local Environment Setup + +Install dependencies using `uv` (recommended) or `pip`: + +```bash +# Install uv package manager +pip install uv + +# Create virtual environment +uv venv + +# Activate virtual environment +source .venv/bin/activate # On macOS/Linux +# OR +.venv\Scripts\activate # On Windows + +# Install dependencies +uv pip install -r pyproject.toml +``` + +## Step 3: Strava API Application + +1. Go to [Strava API Settings](https://www.strava.com/settings/api) +2. Create a new application with these settings: + - **Application Name**: MyWhoosh2Garmin (or your choice) + - **Category**: Training + - **Club**: Leave empty + - **Website**: Your GitHub repo URL + - **Authorization Callback Domain**: `localhost` +3. Note your **Client ID** and **Client Secret** + +## Step 4: Authenticate with Strava + +Run the Strava setup script: + +```bash +python setup_strava_auth.py +``` + +Follow the prompts: +1. Enter your **Client ID** and **Client Secret** +2. Open the authorization URL in your browser +3. Click **Authorize** +4. Copy the full callback URL from your browser (it will look like `http://localhost/exchange_token?code=...`) +5. Paste it back into the terminal + +The script will output all the GitHub Secrets you need. **Keep this window open** β€” you'll need these values in Step 6. + +## Step 5: Authenticate with Garmin + +Run the Garmin setup script: + +```bash +python setup_garmin_auth.py +``` + +Follow the prompts: +1. Enter your Garmin email address +2. Enter your Garmin password +3. If you have 2FA enabled, enter the code when prompted + +The script will output the `GARMIN_TOKENS` secret. **Copy this** β€” you'll need it in Step 6. + +## Step 6: Configure GitHub Secrets + +Now add all the secrets to your GitHub repository: + +1. Go to your repository on GitHub +2. Click **Settings** β†’ **Secrets and variables** β†’ **Actions** +3. Click **New repository secret** for each of the following: + +### From Strava Setup Script + +| Secret Name | Value | +|-------------|-------| +| `STRAVA_CLIENT_ID` | Your Strava Client ID | +| `STRAVA_CLIENT_SECRET` | Your Strava Client Secret | +| `STRAVA_ACCESS_TOKEN` | From script output | +| `STRAVA_EXPIRES_AT` | From script output | +| `STRAVA_EXPIRES_IN` | From script output | +| `STRAVA_REFRESH_TOKEN` | From script output | + +### From Garmin Setup Script + +| Secret Name | Value | +|-------------|-------| +| `GARMIN_TOKENS` | The full token string from script output | + +## Step 7: Test the Workflow + +1. Go to **Actions** tab in your repository +2. Click **Self-hosted runner β€” Run MyWhoosh2Garmin** +3. Click **Run workflow** β†’ **Run workflow** + +The workflow will: +- βœ… Check your recent Garmin activities +- βœ… Fetch MyWhoosh activities from Strava +- βœ… Upload any new activities to Garmin Connect + +Check the workflow logs to verify everything worked! + +## Step 8: Optional - Set Up Webhook for Instant Sync + +For automatic sync immediately after uploading to Strava, you can set up a webhook using my [WebhookProcessor](https://github.com/MarcChen/WebhookProcessor): + +### Quick Setup + +1. Deploy WebhookProcessor to your preferred cloud platform (Vercel, Cloudflare Workers, etc.) +2. Set up the webhook to trigger your GitHub Actions workflow +3. Configure Strava webhook subscription: + ```bash + # Create webhook subscription + curl -X POST https://www.strava.com/api/v3/push_subscriptions \ + -F client_id=YOUR_CLIENT_ID \ + -F client_secret=YOUR_CLIENT_SECRET \ + -F callback_url=YOUR_WEBHOOK_URL \ + -F verify_token=RANDOM_STRING + ``` + +See [WebhookProcessor documentation](https://github.com/MarcChen/WebhookProcessor) for detailed instructions. + +## Troubleshooting + +### Token Expiration + +Strava tokens automatically refresh when expired. If you see authentication errors: +1. Re-run `setup_strava_auth.py` +2. Update the GitHub Secrets with new values + +### Garmin Authentication Failed + +If Garmin authentication fails: +1. Check your username/password are correct +2. If you have 2FA, make sure you're entering the code quickly +3. Re-run `setup_garmin_auth.py` and update `GARMIN_TOKENS` secret + +### No Activities Found + +Make sure: +- Your MyWhoosh activities are uploaded to Strava +- Activity names contain "MyWhoosh" +- Activity type is set to "Virtual Ride" +- You ran a ride within the last 7 days + +### Workflow Fails + +Check the workflow logs in the Actions tab for specific error messages. Common issues: +- Missing or incorrect GitHub Secrets +- Token expiration (re-authenticate and update secrets) +- Network connectivity issues (re-run the workflow) + +## Security Best Practices + +- πŸ”’ **Never commit** `.env` or `strava_tokens.json` to version control (they're gitignored) +- πŸ”’ **Never share** your GitHub Secrets publicly +- πŸ”’ **Rotate tokens** periodically for better security +- πŸ”’ **Use 2FA** on both Strava and Garmin accounts + +## Next Steps + +Once everything is working: +- Set up a webhook for instant sync (Step 8) +- Customize the workflow schedule in `.github/workflows/self-hosted-runner.yml` +- Star this repo if you find it useful! ⭐ diff --git a/garmin/utils.py b/garmin/utils.py index a700789..99b5b69 100644 --- a/garmin/utils.py +++ b/garmin/utils.py @@ -29,7 +29,7 @@ class GarminSettings(BaseSettings): garmin_tokens_path: Path = Field( default=Path(__file__).parent.parent / ".garth", ) - garmin_tokens: str = Field( + garmin_tokens: SecretStr | None = Field( default=None, alias="GARMIN_TOKENS", description="Garmin Connect tokens from garth client.dumps() for authentication.", # noqa: E501 diff --git a/setup_garmin_auth.py b/setup_garmin_auth.py new file mode 100644 index 0000000..a54b0eb --- /dev/null +++ b/setup_garmin_auth.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Garmin Authentication Setup Script + +This script helps you authenticate with Garmin Connect and generate +the GARMIN_TOKENS secret needed for GitHub Actions. + +Usage: + python setup_garmin_auth.py +""" + +import logging + +import garth + +from garmin.utils import GarminSettings, get_credentials_for_garmin + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +def main(): + """Main function to set up Garmin authentication.""" + print("=" * 70) + print("πŸƒ Garmin Connect Authentication Setup") + print("=" * 70) + print() + + # Load settings from .env or prompt for credentials + settings = GarminSettings() + + # Authenticate and save tokens + get_credentials_for_garmin(settings) + + # Dump tokens as string for GitHub secrets + token_string = garth.client.dumps() + + print() + print("=" * 70) + print("βœ… Authentication Successful!") + print("=" * 70) + print() + print("πŸ“‹ Copy the following token string and save it as a GitHub Secret:") + print(" Secret name: GARMIN_TOKENS") + print() + print("-" * 70) + print(token_string) + print("-" * 70) + print() + print("⚠️ IMPORTANT: Keep this token secure! Don't share it publicly.") + print() + print("πŸ”— Add it to GitHub:") + print(" 1. Go to your repository β†’ Settings β†’ Secrets and variables β†’ Actions") + print(" 2. Click 'New repository secret'") + print(" 3. Name: GARMIN_TOKENS") + print(" 4. Value: Paste the token string above") + print() + + +if __name__ == "__main__": + main() diff --git a/setup_strava_auth.py b/setup_strava_auth.py new file mode 100644 index 0000000..67a2c97 --- /dev/null +++ b/setup_strava_auth.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Strava Authentication Setup Script + +This script helps you authenticate with Strava API and generate +the OAuth tokens needed for GitHub Actions. + +Before running this script: +1. Create a Strava API application at https://www.strava.com/settings/api +2. Set the authorization callback domain to: localhost +3. Note your Client ID and Client Secret + +Usage: + python setup_strava_auth.py +""" + +import json +import logging +from pathlib import Path + +from strava.client import StravaAuth, StravaSettings + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +def main(): + """Main function to set up Strava authentication.""" + print("=" * 70) + print("🚴 Strava API Authentication Setup") + print("=" * 70) + print() + print("πŸ“ Before continuing, make sure you have:") + print(" 1. Created a Strava API application at:") + print(" https://www.strava.com/settings/api") + print(" 2. Set authorization callback domain to: localhost") + print() + + # Prompt for Client ID and Secret + client_id = input("Enter your Strava Client ID: ").strip() + client_secret = input("Enter your Strava Client Secret: ").strip() + + if not client_id or not client_secret: + logger.error("❌ Client ID and Secret are required!") + return + + # Create temporary settings with user input + env_file = Path(__file__).parent / ".env" + + # Read existing .env or create from template + env_content = {} + if env_file.exists(): + with open(env_file, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + env_content[key] = value.strip("\"'") + + # Update with new credentials + env_content["STRAVA_CLIENT_ID"] = client_id + env_content["STRAVA_CLIENT_SECRET"] = client_secret + + # Write back to .env + with open(env_file, "w") as f: + f.write("# Strava API Configuration\n") + for key, value in env_content.items(): + f.write(f'{key}="{value}"\n') + + print() + print("πŸ” Starting OAuth flow...") + print() + + # Initialize settings which will now load from .env + settings = StravaSettings() + auth = StravaAuth(settings) + + # Perform OAuth flow + auth._perform_oauth_flow() + + # Load the saved tokens + token_file = Path(__file__).parent / "strava_tokens.json" + if not token_file.exists(): + logger.error("❌ Token file not created. Authentication may have failed.") + return + + with open(token_file, "r") as f: + tokens = json.load(f) + + print() + print("=" * 70) + print("βœ… Authentication Successful!") + print("=" * 70) + print() + print("πŸ“‹ Add these as GitHub Secrets in your repository:") + print(" (Settings β†’ Secrets and variables β†’ Actions β†’ New repository secret)") + print() + print("-" * 70) + print("Secret: STRAVA_CLIENT_ID") + print(f"Value: {client_id}") + print("-" * 70) + print("Secret: STRAVA_CLIENT_SECRET") + print(f"Value: {client_secret}") + print("-" * 70) + print("Secret: STRAVA_ACCESS_TOKEN") + print(f"Value: {tokens.get('access_token', 'N/A')}") + print("-" * 70) + print("Secret: STRAVA_EXPIRES_AT") + print(f"Value: {tokens.get('expires_at', 'N/A')}") + print("-" * 70) + print("Secret: STRAVA_EXPIRES_IN") + print(f"Value: {tokens.get('expires_in', 'N/A')}") + print("-" * 70) + print("Secret: STRAVA_REFRESH_TOKEN") + print(f"Value: {tokens.get('refresh_token', 'N/A')}") + print("-" * 70) + print() + print("⚠️ IMPORTANT: Keep these tokens secure! Don't share them publicly.") + print() + print("πŸ’Ύ Tokens have also been saved to: strava_tokens.json") + print(" (This file is gitignored for your security)") + print() + + +if __name__ == "__main__": + main() diff --git a/strava/README.md b/strava/README.md deleted file mode 100644 index 1c58073..0000000 --- a/strava/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# What does it do - -strava.py connects to the API to get all the activities and then uses your Strava cookie to login and get your .fit file from https://www.strava.com/activities/{activity.id}/export_original. -I did this because we are not allowed to scrape MyWhoosh site. It's in the TOS. - -## Installation - -### Register your app on Strava - -1. Go to [Strava API settings](https://www.strava.com/settings/api) -2. Get your Strava cookie and turn it into a cookies.json -3. To continue, hold on diff --git a/strava/client.py b/strava/client.py index b40d887..5bae53a 100644 --- a/strava/client.py +++ b/strava/client.py @@ -25,21 +25,27 @@ class StravaSettings(BaseSettings): """Configuration settings for Strava API client.""" - client_id: str | None = Field( + client_id: str + client_secret: SecretStr + token_type: str = Field( + default="Bearer", + ) + access_token: SecretStr | None = Field( default=None, - description="Strava API Client ID necessary to generate access tokens.", + description="Strava API Access Token.", ) - client_secret: SecretStr | None = Field( + expires_at: int | None = Field( default=None, - description="Strava API Client Secret necessary to generate access tokens.", + description="Strava API Access Token Expiration Time.", ) - token_type: str = Field( - default="Bearer", + expires_in: int | None = Field( + default=None, + description="Strava API Access Token Expiration Time.", + ) + refresh_token: SecretStr | None = Field( + default=None, + description="Strava API Refresh Token.", ) - access_token: SecretStr - expires_at: int - expires_in: int - refresh_token: SecretStr token_url: str = "https://www.strava.com/oauth/token" auth_base_url: str = "https://www.strava.com/oauth/authorize" token_file: Path = Path(__file__).parent.parent / "strava_tokens.json" From c6393b79f6e111cc8e4c060d58db8eff7e3c4724 Mon Sep 17 00:00:00 2001 From: MarcChen <128506536+MarcChen@users.noreply.github.com> Date: Sun, 23 Nov 2025 16:38:35 +0100 Subject: [PATCH 8/9] feat: Enhance Garmin authentication script to require credentials and securely store them in .env file --- setup_garmin_auth.py | 44 +++++++++++++++++++++++++++++++++++++++++--- strava/client.py | 2 +- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/setup_garmin_auth.py b/setup_garmin_auth.py index a54b0eb..a2a93fb 100644 --- a/setup_garmin_auth.py +++ b/setup_garmin_auth.py @@ -10,6 +10,7 @@ """ import logging +from pathlib import Path import garth @@ -28,7 +29,37 @@ def main(): print("=" * 70) print() - # Load settings from .env or prompt for credentials + # Prompt for Garmin credentials + username = input("Enter your Garmin Connect Username (Email): ").strip() + password = input("Enter your Garmin Connect Password: ").strip() + + if not username or not password: + logger.error("❌ Username and Password are required!") + return + + # Create/update .env file with credentials + env_file = Path(__file__).parent / ".env" + env_content = {} + if env_file.exists(): + with open(env_file, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + env_content[key.strip()] = value.strip("\"'") + + env_content["GARMIN_USERNAME"] = username + env_content["GARMIN_PASSWORD"] = password + + with open(env_file, "w") as f: + for key, value in env_content.items(): + f.write(f'{key}="{value}"\n') + + print() + print("πŸ” Starting authentication...") + print() + + # Load settings from .env settings = GarminSettings() # Authenticate and save tokens @@ -43,13 +74,20 @@ def main(): print("=" * 70) print() print("πŸ“‹ Copy the following token string and save it as a GitHub Secret:") - print(" Secret name: GARMIN_TOKENS") + print(" This is the recommended way to authenticate in CI/CD environments.") print() print("-" * 70) + print("Secret: GARMIN_TOKENS") print(token_string) print("-" * 70) print() - print("⚠️ IMPORTANT: Keep this token secure! Don't share it publicly.") + print("Alternatively, you can use your username and password as secrets:") + print("Secret: GARMIN_USERNAME") + print(f"Value: {username}") + print("Secret: GARMIN_PASSWORD") + print(f"Value: {password}") + print() + print("⚠️ IMPORTANT: Keep these tokens and credentials secure!") print() print("πŸ”— Add it to GitHub:") print(" 1. Go to your repository β†’ Settings β†’ Secrets and variables β†’ Actions") diff --git a/strava/client.py b/strava/client.py index 5bae53a..2e2349d 100644 --- a/strava/client.py +++ b/strava/client.py @@ -64,7 +64,7 @@ def model_post_init(self, __context: Any) -> None: # Create token file if it doesn't exist token_path = self.token_file # Only dump selected fields to token file if it doesn't exist - if not token_path.exists(): + if not token_path.exists() and self.access_token and self.refresh_token: token_data = { "token_type": self.token_type, "access_token": str(self.access_token.get_secret_value()), From bd256d09b3bd8e67bf9c84e68067ab139cfecb37 Mon Sep 17 00:00:00 2001 From: Marc Chen - Tour <128506536+MarcChen@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:13:58 +0100 Subject: [PATCH 9/9] feat: Introduce `sanitize_filename` utility to ensure activity names are valid for file paths. --- myWhoosh2Garmin.py | 6 ++++-- strava/client.py | 5 ++++- strava/utils.py | 13 +++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 strava/utils.py diff --git a/myWhoosh2Garmin.py b/myWhoosh2Garmin.py index e3a6f07..dfe54f3 100644 --- a/myWhoosh2Garmin.py +++ b/myWhoosh2Garmin.py @@ -8,6 +8,7 @@ upload_fit_file_to_garmin, ) from strava.client import StravaClientBuilder +from strava.utils import sanitize_filename SCRIPT_DIR = Path(__file__).resolve().parent log_file_path = SCRIPT_DIR / "myWhoosh2Garmin.log" @@ -46,9 +47,10 @@ def strip_timezone(dt): for activity in new_activities: client.downloader.download_activity(activity.id) - file_name = f"{activity.name}.json" + safe_name = sanitize_filename(activity.name) + file_name = f"{safe_name}.json" input_path = RAW_FIT_FILE_PATH / file_name - output_path = RAW_FIT_FILE_PATH.parent / "processed" / f"{activity.name}.fit" + output_path = RAW_FIT_FILE_PATH.parent / "processed" / f"{safe_name}.fit" builder = MyWhooshFitBuilder(input_path) builder.build(output_path) upload_fit_file_to_garmin(output_path) diff --git a/strava/client.py b/strava/client.py index 2e2349d..b8c36b4 100644 --- a/strava/client.py +++ b/strava/client.py @@ -19,6 +19,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from requests import Session +from strava.utils import sanitize_filename + logger = logging.getLogger(__name__) @@ -353,7 +355,8 @@ def _download_attempt(self, activity_id: int) -> bool: data_dir = Path(__file__).parent.parent / "data" / "raw" data_dir.mkdir(parents=True, exist_ok=True) - json_filename = data_dir / f"{activity_name}.json" + safe_activity_name = sanitize_filename(activity_name) + json_filename = data_dir / f"{safe_activity_name}.json" with open(json_filename, "w") as f: json.dump(combined_data, f, indent=2, default=str) diff --git a/strava/utils.py b/strava/utils.py new file mode 100644 index 0000000..03e2c5e --- /dev/null +++ b/strava/utils.py @@ -0,0 +1,13 @@ + +import re + +def sanitize_filename(name: str) -> str: + r""" + Sanitize the string to be safe for filenames. + Replaces special characters (Windows invalid characters) with underscores. + Invalid characters: < > : " / \ | ? * + """ + # Replace invalid characters with underscore + sanitized = re.sub(r'[<>:"/\\|?*]', '_', name) + # Strip leading/trailing whitespace + return sanitized.strip()