steamsift 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. steamsift-0.1.0/LICENSE +21 -0
  2. steamsift-0.1.0/PKG-INFO +158 -0
  3. steamsift-0.1.0/README.md +115 -0
  4. steamsift-0.1.0/pyproject.toml +63 -0
  5. steamsift-0.1.0/setup.cfg +4 -0
  6. steamsift-0.1.0/steamsift/__init__.py +3 -0
  7. steamsift-0.1.0/steamsift/__main__.py +4 -0
  8. steamsift-0.1.0/steamsift/autodetect.py +106 -0
  9. steamsift-0.1.0/steamsift/calendar.py +145 -0
  10. steamsift-0.1.0/steamsift/cli.py +908 -0
  11. steamsift-0.1.0/steamsift/config.py +391 -0
  12. steamsift-0.1.0/steamsift/data/regions.json +2475 -0
  13. steamsift-0.1.0/steamsift/db.py +199 -0
  14. steamsift-0.1.0/steamsift/discover.py +226 -0
  15. steamsift-0.1.0/steamsift/doctor.py +173 -0
  16. steamsift-0.1.0/steamsift/engine.py +411 -0
  17. steamsift-0.1.0/steamsift/i18n.py +929 -0
  18. steamsift-0.1.0/steamsift/itad.py +455 -0
  19. steamsift-0.1.0/steamsift/localsteam.py +162 -0
  20. steamsift-0.1.0/steamsift/net.py +108 -0
  21. steamsift-0.1.0/steamsift/notify/__init__.py +1 -0
  22. steamsift-0.1.0/steamsift/notify/cards.py +202 -0
  23. steamsift-0.1.0/steamsift/notify/channels.py +153 -0
  24. steamsift-0.1.0/steamsift/notify/events.py +149 -0
  25. steamsift-0.1.0/steamsift/notify/runner.py +239 -0
  26. steamsift-0.1.0/steamsift/notify/telegram.py +215 -0
  27. steamsift-0.1.0/steamsift/prefs.py +111 -0
  28. steamsift-0.1.0/steamsift/presentation.py +36 -0
  29. steamsift-0.1.0/steamsift/query.py +445 -0
  30. steamsift-0.1.0/steamsift/recommender.py +455 -0
  31. steamsift-0.1.0/steamsift/region.py +152 -0
  32. steamsift-0.1.0/steamsift/schedule.py +315 -0
  33. steamsift-0.1.0/steamsift/settings_registry.py +288 -0
  34. steamsift-0.1.0/steamsift/steam.py +584 -0
  35. steamsift-0.1.0/steamsift/steamid.py +79 -0
  36. steamsift-0.1.0/steamsift/tui/__init__.py +4 -0
  37. steamsift-0.1.0/steamsift/tui/app.py +1180 -0
  38. steamsift-0.1.0/steamsift/tui/cover.py +70 -0
  39. steamsift-0.1.0/steamsift/tui/theme.py +47 -0
  40. steamsift-0.1.0/steamsift/tui/wizard.py +769 -0
  41. steamsift-0.1.0/steamsift/ui.py +503 -0
  42. steamsift-0.1.0/steamsift.egg-info/PKG-INFO +158 -0
  43. steamsift-0.1.0/steamsift.egg-info/SOURCES.txt +62 -0
  44. steamsift-0.1.0/steamsift.egg-info/dependency_links.txt +1 -0
  45. steamsift-0.1.0/steamsift.egg-info/entry_points.txt +4 -0
  46. steamsift-0.1.0/steamsift.egg-info/requires.txt +15 -0
  47. steamsift-0.1.0/steamsift.egg-info/top_level.txt +1 -0
  48. steamsift-0.1.0/tests/test_channels.py +240 -0
  49. steamsift-0.1.0/tests/test_config_i18n.py +63 -0
  50. steamsift-0.1.0/tests/test_discover.py +125 -0
  51. steamsift-0.1.0/tests/test_events.py +161 -0
  52. steamsift-0.1.0/tests/test_filters.py +113 -0
  53. steamsift-0.1.0/tests/test_history.py +352 -0
  54. steamsift-0.1.0/tests/test_items.py +86 -0
  55. steamsift-0.1.0/tests/test_learning.py +68 -0
  56. steamsift-0.1.0/tests/test_packaging.py +84 -0
  57. steamsift-0.1.0/tests/test_query.py +209 -0
  58. steamsift-0.1.0/tests/test_recommender.py +129 -0
  59. steamsift-0.1.0/tests/test_region.py +71 -0
  60. steamsift-0.1.0/tests/test_schedule.py +72 -0
  61. steamsift-0.1.0/tests/test_smarter.py +93 -0
  62. steamsift-0.1.0/tests/test_steamid_localsteam.py +78 -0
  63. steamsift-0.1.0/tests/test_tui.py +454 -0
  64. steamsift-0.1.0/tests/test_wizard.py +215 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sift contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: steamsift
3
+ Version: 0.1.0
4
+ Summary: Steam deals, sifted: every discount checked against two years of prices. HOT, GREAT, OK, MEH or NOPE.
5
+ Author: sa1ntsinner
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://sa1ntsinner.github.io/sift/
8
+ Project-URL: Source, https://github.com/sa1ntsinner/sift
9
+ Project-URL: Issues, https://github.com/sa1ntsinner/sift/issues
10
+ Keywords: steam,deals,sale,wishlist,recommendations,telegram,cli,tui
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Natural Language :: English
15
+ Classifier: Natural Language :: Russian
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Games/Entertainment
25
+ Requires-Python: >=3.11
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: rich>=13.0.0
29
+ Requires-Dist: requests>=2.28.0
30
+ Requires-Dist: python-dotenv>=1.0.0
31
+ Requires-Dist: platformdirs>=4.0
32
+ Requires-Dist: keyring>=24.0
33
+ Requires-Dist: tomli-w>=1.0
34
+ Requires-Dist: textual>=1.0
35
+ Requires-Dist: textual-image>=0.8
36
+ Requires-Dist: pillow>=10.0
37
+ Provides-Extra: dev
38
+ Requires-Dist: pytest>=8; extra == "dev"
39
+ Requires-Dist: pyyaml>=6; extra == "dev"
40
+ Requires-Dist: build; extra == "dev"
41
+ Requires-Dist: twine; extra == "dev"
42
+ Dynamic: license-file
43
+
44
+ <p align="center">
45
+ <img src="https://raw.githubusercontent.com/sa1ntsinner/sift/main/assets/sift-wordmark.png" alt="sift" width="200" />
46
+ </p>
47
+
48
+ <p align="center"><em>Steam has thousands of discounts. sift tells you which ones are worth it.</em></p>
49
+
50
+ <p align="center">
51
+ <a href="https://sa1ntsinner.github.io/sift/"><img src="https://img.shields.io/badge/website-sift-2bff88?style=flat" alt="Website" /></a>
52
+ &nbsp;
53
+ <a href="https://github.com/sa1ntsinner/sift/releases/latest"><img src="https://img.shields.io/github/v/release/sa1ntsinner/sift?style=flat&color=24292f" alt="Latest release" /></a>
54
+ &nbsp;
55
+ <img src="https://img.shields.io/badge/Windows%20·%20macOS%20·%20Linux-24292f?style=flat" alt="Windows, macOS, Linux" />
56
+ &nbsp;
57
+ <a href="https://pypi.org/project/steamsift/"><img src="https://img.shields.io/pypi/v/steamsift?style=flat&color=24292f&label=pypi" alt="PyPI" /></a>
58
+ &nbsp;
59
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-24292f?style=flat" alt="MIT license" /></a>
60
+ </p>
61
+
62
+ <p align="center">
63
+ <img src="https://raw.githubusercontent.com/sa1ntsinner/sift/main/assets/sift.gif" alt="sift in a terminal: a list of Steam deals with a verdict on each, the filter panel and the wishlist" width="100%" />
64
+ </p>
65
+
66
+ sift is a free terminal app for Steam deals. It reads two years of Steam prices for every discount, checks how often
67
+ the game goes on sale, whether the base price was just raised, whether a big sale is days away and what your Steam
68
+ Family already owns, and gives each deal one word.
69
+
70
+ Most "record lows" on Steam come back every five or six weeks. sift tells those apart from the prices that rarely
71
+ show up, so you buy the right game at the right moment and skip the rest.
72
+
73
+ ## Verdicts
74
+
75
+ | | |
76
+ |---|---|
77
+ | **HOT** | A price that rarely comes back: a rare record, the lowest in two years, or a cheaper bundle. |
78
+ | **GREAT** | A record price, but it returns every few weeks. Buy it when you will play it. |
79
+ | **OK** | An ordinary discount for this game. |
80
+ | **MEH** | It has been 12–30% cheaper, or a big Steam sale is under three weeks away. |
81
+ | **NOPE** | It has been much cheaper, or the base price went up just before the sale. |
82
+
83
+ Prices are compared in what you pay, not in percent: $15 at −60% is not a record if the game once sold for $5.
84
+
85
+ ## Install
86
+
87
+ **Download** one file from the [latest release](https://github.com/sa1ntsinner/sift/releases/latest):
88
+ `sift-windows-x64.exe`, `sift-macos-arm64` or `sift-linux-x64`. Nothing else to install.
89
+
90
+ **Or with Python 3.11+**
91
+
92
+ ```bash
93
+ uv tool install steamsift # or: pipx install steamsift
94
+ sift
95
+ ```
96
+
97
+ ## First run
98
+
99
+ `sift` opens a three-minute setup. It can look on your computer for your Steam account and Steam Family (only Steam's
100
+ own files, nothing is sent anywhere). You paste two free keys, and sift picks them up from the clipboard as soon as you
101
+ copy them:
102
+
103
+ | | where | |
104
+ |---|---|---|
105
+ | Steam Web API key | [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) | domain: `localhost` · confirm in the Steam mobile app |
106
+ | IsThereAnyDeal API key | [isthereanydeal.com/apps/my](https://isthereanydeal.com/apps/my/) | Register app → copy the API key · optional, but it powers the verdicts |
107
+
108
+ Steam only issues API keys to accounts that have spent at least $5 and use Steam Guard Mobile.
109
+
110
+ ## Using it
111
+
112
+ The app has four tabs (Deals · Wishlist · Discover · DLC), a verdict on every row and the game's two-year sale history
113
+ next to it. Everything works from the keyboard:
114
+
115
+ | key | | key | |
116
+ |---|---|---|---|
117
+ | `↑ ↓` `j k` | move | `/` | search |
118
+ | `enter` | open in Steam | `f` | filters, saved filters, hidden games |
119
+ | `1–4` `← →` | tabs | `s` | sort |
120
+ | `x` | hide: not for me | `u` | undo |
121
+ | `i` | price history on IsThereAnyDeal | `?` | every key |
122
+
123
+ The same filters work as flags, so a search can be one line:
124
+
125
+ ```bash
126
+ sift --hot -b 10 # only HOT deals under 10 in your store's currency
127
+ sift -g roguelike --deck # roguelikes that run on the Steam Deck
128
+ sift macro add cheap -b 2 --lowest && sift cheap
129
+ sift info "hollow knight" # price, history and verdict for one game
130
+ sift --print # a plain table for scripts; sift -J prints JSON
131
+ ```
132
+
133
+ ## Alerts
134
+
135
+ `sift notify setup telegram` (or `discord`, `ntfy`, `desktop`, `email`) sends a few cards a day: HOT prices, your own
136
+ price targets and wishlist records that are about to end. Never the whole sale.
137
+
138
+ Run it on a schedule with `sift notify enable`, keep it running with `sift watch`, or run it in the cloud with
139
+ [GitHub Actions](packaging/github-actions/sift.yml) or [Docker](compose.yaml) while your PC is off.
140
+
141
+ ## What it knows
142
+
143
+ - **Your store.** Prices in the currency Steam charges you, in whichever Steam region your account is in.
144
+ - **Your Steam Family.** Shared games stay out of the list, or show with a blue mark if you want them.
145
+ - **Your library.** Owned and hidden games never show up; DLC is listed for the games you own.
146
+ - **Your taste, second.** Deals come first; your play history breaks ties and fills the Discover tab.
147
+
148
+ Everything stays on your computer: keys in the system keychain, settings in a local file. sift talks only to Steam and
149
+ IsThereAnyDeal.
150
+
151
+ ## More
152
+
153
+ `sift doctor` checks keys, privacy settings, region and family. `sift config help` lists every setting.
154
+ `sift calendar` shows the Steam sales ahead. The app speaks English and Russian (`sift config set lang ru`).
155
+
156
+ ---
157
+
158
+ <p align="center"><sub>MIT license · not affiliated with Valve · price history from <a href="https://isthereanydeal.com">IsThereAnyDeal</a></sub></p>
@@ -0,0 +1,115 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/sa1ntsinner/sift/main/assets/sift-wordmark.png" alt="sift" width="200" />
3
+ </p>
4
+
5
+ <p align="center"><em>Steam has thousands of discounts. sift tells you which ones are worth it.</em></p>
6
+
7
+ <p align="center">
8
+ <a href="https://sa1ntsinner.github.io/sift/"><img src="https://img.shields.io/badge/website-sift-2bff88?style=flat" alt="Website" /></a>
9
+ &nbsp;
10
+ <a href="https://github.com/sa1ntsinner/sift/releases/latest"><img src="https://img.shields.io/github/v/release/sa1ntsinner/sift?style=flat&color=24292f" alt="Latest release" /></a>
11
+ &nbsp;
12
+ <img src="https://img.shields.io/badge/Windows%20·%20macOS%20·%20Linux-24292f?style=flat" alt="Windows, macOS, Linux" />
13
+ &nbsp;
14
+ <a href="https://pypi.org/project/steamsift/"><img src="https://img.shields.io/pypi/v/steamsift?style=flat&color=24292f&label=pypi" alt="PyPI" /></a>
15
+ &nbsp;
16
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-24292f?style=flat" alt="MIT license" /></a>
17
+ </p>
18
+
19
+ <p align="center">
20
+ <img src="https://raw.githubusercontent.com/sa1ntsinner/sift/main/assets/sift.gif" alt="sift in a terminal: a list of Steam deals with a verdict on each, the filter panel and the wishlist" width="100%" />
21
+ </p>
22
+
23
+ sift is a free terminal app for Steam deals. It reads two years of Steam prices for every discount, checks how often
24
+ the game goes on sale, whether the base price was just raised, whether a big sale is days away and what your Steam
25
+ Family already owns, and gives each deal one word.
26
+
27
+ Most "record lows" on Steam come back every five or six weeks. sift tells those apart from the prices that rarely
28
+ show up, so you buy the right game at the right moment and skip the rest.
29
+
30
+ ## Verdicts
31
+
32
+ | | |
33
+ |---|---|
34
+ | **HOT** | A price that rarely comes back: a rare record, the lowest in two years, or a cheaper bundle. |
35
+ | **GREAT** | A record price, but it returns every few weeks. Buy it when you will play it. |
36
+ | **OK** | An ordinary discount for this game. |
37
+ | **MEH** | It has been 12–30% cheaper, or a big Steam sale is under three weeks away. |
38
+ | **NOPE** | It has been much cheaper, or the base price went up just before the sale. |
39
+
40
+ Prices are compared in what you pay, not in percent: $15 at −60% is not a record if the game once sold for $5.
41
+
42
+ ## Install
43
+
44
+ **Download** one file from the [latest release](https://github.com/sa1ntsinner/sift/releases/latest):
45
+ `sift-windows-x64.exe`, `sift-macos-arm64` or `sift-linux-x64`. Nothing else to install.
46
+
47
+ **Or with Python 3.11+**
48
+
49
+ ```bash
50
+ uv tool install steamsift # or: pipx install steamsift
51
+ sift
52
+ ```
53
+
54
+ ## First run
55
+
56
+ `sift` opens a three-minute setup. It can look on your computer for your Steam account and Steam Family (only Steam's
57
+ own files, nothing is sent anywhere). You paste two free keys, and sift picks them up from the clipboard as soon as you
58
+ copy them:
59
+
60
+ | | where | |
61
+ |---|---|---|
62
+ | Steam Web API key | [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) | domain: `localhost` · confirm in the Steam mobile app |
63
+ | IsThereAnyDeal API key | [isthereanydeal.com/apps/my](https://isthereanydeal.com/apps/my/) | Register app → copy the API key · optional, but it powers the verdicts |
64
+
65
+ Steam only issues API keys to accounts that have spent at least $5 and use Steam Guard Mobile.
66
+
67
+ ## Using it
68
+
69
+ The app has four tabs (Deals · Wishlist · Discover · DLC), a verdict on every row and the game's two-year sale history
70
+ next to it. Everything works from the keyboard:
71
+
72
+ | key | | key | |
73
+ |---|---|---|---|
74
+ | `↑ ↓` `j k` | move | `/` | search |
75
+ | `enter` | open in Steam | `f` | filters, saved filters, hidden games |
76
+ | `1–4` `← →` | tabs | `s` | sort |
77
+ | `x` | hide: not for me | `u` | undo |
78
+ | `i` | price history on IsThereAnyDeal | `?` | every key |
79
+
80
+ The same filters work as flags, so a search can be one line:
81
+
82
+ ```bash
83
+ sift --hot -b 10 # only HOT deals under 10 in your store's currency
84
+ sift -g roguelike --deck # roguelikes that run on the Steam Deck
85
+ sift macro add cheap -b 2 --lowest && sift cheap
86
+ sift info "hollow knight" # price, history and verdict for one game
87
+ sift --print # a plain table for scripts; sift -J prints JSON
88
+ ```
89
+
90
+ ## Alerts
91
+
92
+ `sift notify setup telegram` (or `discord`, `ntfy`, `desktop`, `email`) sends a few cards a day: HOT prices, your own
93
+ price targets and wishlist records that are about to end. Never the whole sale.
94
+
95
+ Run it on a schedule with `sift notify enable`, keep it running with `sift watch`, or run it in the cloud with
96
+ [GitHub Actions](packaging/github-actions/sift.yml) or [Docker](compose.yaml) while your PC is off.
97
+
98
+ ## What it knows
99
+
100
+ - **Your store.** Prices in the currency Steam charges you, in whichever Steam region your account is in.
101
+ - **Your Steam Family.** Shared games stay out of the list, or show with a blue mark if you want them.
102
+ - **Your library.** Owned and hidden games never show up; DLC is listed for the games you own.
103
+ - **Your taste, second.** Deals come first; your play history breaks ties and fills the Discover tab.
104
+
105
+ Everything stays on your computer: keys in the system keychain, settings in a local file. sift talks only to Steam and
106
+ IsThereAnyDeal.
107
+
108
+ ## More
109
+
110
+ `sift doctor` checks keys, privacy settings, region and family. `sift config help` lists every setting.
111
+ `sift calendar` shows the Steam sales ahead. The app speaks English and Russian (`sift config set lang ru`).
112
+
113
+ ---
114
+
115
+ <p align="center"><sub>MIT license · not affiliated with Valve · price history from <a href="https://isthereanydeal.com">IsThereAnyDeal</a></sub></p>
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "steamsift"
7
+ version = "0.1.0"
8
+ description = "Steam deals, sifted: every discount checked against two years of prices. HOT, GREAT, OK, MEH or NOPE."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "sa1ntsinner" }]
14
+ keywords = ["steam", "deals", "sale", "wishlist", "recommendations", "telegram", "cli", "tui"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "Natural Language :: English",
20
+ "Natural Language :: Russian",
21
+ "Operating System :: Microsoft :: Windows",
22
+ "Operating System :: MacOS",
23
+ "Operating System :: POSIX :: Linux",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Topic :: Games/Entertainment",
30
+ ]
31
+ dependencies = [
32
+ "rich>=13.0.0",
33
+ "requests>=2.28.0",
34
+ "python-dotenv>=1.0.0",
35
+ "platformdirs>=4.0",
36
+ "keyring>=24.0",
37
+ "tomli-w>=1.0",
38
+ "textual>=1.0",
39
+ "textual-image>=0.8",
40
+ "pillow>=10.0",
41
+ ]
42
+
43
+ [project.optional-dependencies]
44
+ dev = ["pytest>=8", "pyyaml>=6", "build", "twine"]
45
+
46
+ [project.urls]
47
+ Homepage = "https://sa1ntsinner.github.io/sift/"
48
+ Source = "https://github.com/sa1ntsinner/sift"
49
+ Issues = "https://github.com/sa1ntsinner/sift/issues"
50
+
51
+ [project.scripts]
52
+ sift = "steamsift.cli:entry"
53
+ steamsift = "steamsift.cli:entry"
54
+ ss = "steamsift.cli:entry"
55
+
56
+ [tool.setuptools]
57
+ packages = ["steamsift", "steamsift.notify", "steamsift.tui"]
58
+
59
+ [tool.setuptools.package-data]
60
+ steamsift = ["data/*.json"]
61
+
62
+ [tool.pytest.ini_options]
63
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """sift — taste-aware Steam deals for you and your Steam Family."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import entry
2
+
3
+ if __name__ == "__main__":
4
+ entry()
@@ -0,0 +1,106 @@
1
+ """What setup can find on this computer, only when the user agrees: Steam accounts, Steam Family, keys.
2
+
3
+ Nothing is sent anywhere. We read Steam's own text files (config/loginusers.vdf, userdata/*/localconfig.vdf)
4
+ and, while a key step is open, the clipboard, so a key copied from the browser fills itself in.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from dataclasses import dataclass, field
13
+
14
+ from .localsteam import ci_get, family_from_client, parse_vdf, steam_roots
15
+
16
+ STEAM_KEY = re.compile(r"\b[0-9A-F]{32}\b")
17
+ ITAD_KEY = re.compile(r"\b[0-9a-f]{40}\b")
18
+
19
+
20
+ @dataclass
21
+ class Account:
22
+ steam_id: str
23
+ persona: str
24
+ recent: bool = False
25
+ stamp: int = 0
26
+
27
+
28
+ @dataclass
29
+ class Found:
30
+ steam_root: str = ""
31
+ accounts: list[Account] = field(default_factory=list)
32
+ family: dict = field(default_factory=dict)
33
+
34
+ @property
35
+ def account(self) -> Account | None:
36
+ return self.accounts[0] if self.accounts else None
37
+
38
+
39
+ def accounts() -> list[Account]:
40
+ """Accounts that signed in to Steam on this PC, the one used last first."""
41
+ out: dict[str, Account] = {}
42
+ for root in steam_roots():
43
+ path = root / "config" / "loginusers.vdf"
44
+ try:
45
+ users = ci_get(parse_vdf(path.read_text(encoding="utf-8", errors="ignore")), "users") or {}
46
+ except OSError:
47
+ continue
48
+ for sid, u in users.items() if isinstance(users, dict) else []:
49
+ if not (isinstance(u, dict) and str(sid).isdigit() and len(str(sid)) == 17):
50
+ continue
51
+ stamp = ci_get(u, "Timestamp") or "0"
52
+ out.setdefault(sid, Account(sid, ci_get(u, "PersonaName") or "", ci_get(u, "MostRecent") == "1",
53
+ int(stamp) if str(stamp).isdigit() else 0))
54
+ return sorted(out.values(), key=lambda a: (not a.recent, -a.stamp))
55
+
56
+
57
+ def scan() -> Found:
58
+ roots = steam_roots()
59
+ found = Found(steam_root=str(roots[0]) if roots else "", accounts=accounts())
60
+ if found.account:
61
+ found.family = family_from_client(found.account.steam_id)
62
+ return found
63
+
64
+
65
+ def clipboard_text() -> str:
66
+ """The clipboard as text, or '' when it can't be read (no display, no tool, not text)."""
67
+ try:
68
+ if sys.platform == "win32":
69
+ return _windows_clipboard()
70
+ for cmd in (["pbpaste"], ["wl-paste", "--no-newline"], ["xclip", "-selection", "clipboard", "-o"],
71
+ ["xsel", "--clipboard", "--output"]):
72
+ if shutil.which(cmd[0]):
73
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=2).stdout
74
+ except (OSError, subprocess.SubprocessError, ValueError):
75
+ pass
76
+ return ""
77
+
78
+
79
+ def _windows_clipboard() -> str:
80
+ import ctypes
81
+ from ctypes import wintypes
82
+ user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32
83
+ user32.GetClipboardData.restype = wintypes.HANDLE
84
+ kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
85
+ kernel32.GlobalLock.restype = wintypes.LPVOID
86
+ kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
87
+ CF_UNICODETEXT = 13
88
+ if not user32.OpenClipboard(None):
89
+ return ""
90
+ try:
91
+ handle = user32.GetClipboardData(CF_UNICODETEXT)
92
+ if not handle:
93
+ return ""
94
+ ptr = kernel32.GlobalLock(handle)
95
+ try:
96
+ return ctypes.wstring_at(ptr) if ptr else ""
97
+ finally:
98
+ kernel32.GlobalUnlock(handle)
99
+ finally:
100
+ user32.CloseClipboard()
101
+
102
+
103
+ def key_in(text: str, kind: str) -> str:
104
+ """The first Steam (32 hex, upper case) or ITAD (40 hex, lower case) key in `text`."""
105
+ m = (STEAM_KEY if kind == "steam" else ITAD_KEY).search(text or "")
106
+ return m.group(0) if m else ""
@@ -0,0 +1,145 @@
1
+ """Steam sale calendar, parsed from Valve's public partner page (seasonal sales + themed fests).
2
+
3
+ Used for "wait — the Summer Sale starts in 9 days" hints and for fests matching your taste.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import datetime as dt
8
+ import html as htmllib
9
+ import re
10
+ from dataclasses import dataclass, field
11
+
12
+ from .db import DAY, Cache
13
+ from .net import SESSION
14
+
15
+ URL = "https://partner.steamgames.com/doc/marketing/upcoming_events"
16
+ _cache = Cache("calendar")
17
+
18
+ # Themed fests -> the Steam tags whose games usually get discounted during them.
19
+ FEST_TAGS: dict[str, list[str]] = {
20
+ "Racing Fest": ["Racing", "Driving", "Automobile Sim"],
21
+ "Steam Scream": ["Horror", "Psychological Horror", "Survival Horror"],
22
+ "Real-Time Strategy Fest": ["RTS", "Strategy"],
23
+ "Fighting Fest": ["Fighting", "2D Fighter", "3D Fighter", "Beat 'em up"],
24
+ "Rhythm Fest": ["Rhythm", "Music"],
25
+ "Couch Co-Op Fest": ["Local Co-Op", "Local Multiplayer"],
26
+ "Cooking Fest": ["Cooking"],
27
+ "Shop Keeper Fest": ["Management", "Economy"],
28
+ "Dinos vs. Robots Fest": ["Dinosaurs", "Robots", "Mechs"],
29
+ "Witch Fest": ["Magic", "Witch"],
30
+ "Mountaineering Fest": ["Climbing"],
31
+ "Medieval Fest": ["Medieval"],
32
+ "Auto-Battler RPG Fest": ["Auto Battler"],
33
+ "Survival Fest": ["Survival"],
34
+ "Open World Survival Craft Fest": ["Open World Survival Craft"],
35
+ "Simulation Fest": ["Simulation"],
36
+ "Puzzle Fest": ["Puzzle"],
37
+ "Sports Fest": ["Sports"],
38
+ "Strategy Fest": ["Strategy"],
39
+ }
40
+
41
+ _MONTHS = {m: i for i, m in enumerate(
42
+ ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], 1)}
43
+
44
+
45
+ @dataclass
46
+ class Event:
47
+ name: str
48
+ kind: str # "sale" (seasonal, store-wide) | "fest" (themed)
49
+ start: dt.date
50
+ end: dt.date
51
+ tags: list[str] = field(default_factory=list)
52
+
53
+ def to_json(self) -> dict:
54
+ return {"name": self.name, "kind": self.kind, "start": self.start.isoformat(),
55
+ "end": self.end.isoformat(), "tags": self.tags}
56
+
57
+ @classmethod
58
+ def from_json(cls, d: dict) -> "Event":
59
+ return cls(d["name"], d["kind"], dt.date.fromisoformat(d["start"]), dt.date.fromisoformat(d["end"]),
60
+ d.get("tags", []))
61
+
62
+
63
+ def _date(text: str) -> dt.date | None:
64
+ m = re.match(r"(\d{1,2})\s+([A-Za-z]+),?\s+(\d{4})", text.strip())
65
+ if not m:
66
+ return None
67
+ month = _MONTHS.get(m.group(2)[:3].lower())
68
+ return dt.date(int(m.group(3)), month, int(m.group(1))) if month else None
69
+
70
+
71
+ def _plain(fragment: str) -> str:
72
+ return re.sub(r"\s+", " ", htmllib.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip()
73
+
74
+
75
+ def parse(page: str) -> list[Event]:
76
+ events: list[Event] = []
77
+ text = _plain(page)
78
+ for m in re.finditer(r"(Spring|Summer|Autumn|Winter) Sale (\d{4})\s*\|?\s*(\d{1,2} [A-Za-z]+,? \d{4})\s*[-–]\s*"
79
+ r"(\d{1,2} [A-Za-z]+,? \d{4})", text):
80
+ start, end = _date(m.group(3)), _date(m.group(4))
81
+ if start and end:
82
+ events.append(Event(f"Steam {m.group(1)} Sale", "sale", start, end))
83
+
84
+ # Themed fests live in tables that follow "<year> Fests" headings.
85
+ year = None
86
+ for m in re.finditer(r"(20\d\d) Fests|<tr[^>]*>(.*?)</tr>", page, re.S):
87
+ if m.group(1):
88
+ year = int(m.group(1))
89
+ continue
90
+ if year is None:
91
+ continue
92
+ cells = [_plain(c) for c in re.findall(r"<t[dh][^>]*>(.*?)</t[dh]>", m.group(2), re.S)]
93
+ if len(cells) < 2:
94
+ continue
95
+ d = re.match(r"([A-Za-z]{3})[a-z]*\.? (\d{1,2})\s*[-–]?\s*(?:([A-Za-z]{3})[a-z]*\.? )?(\d{1,2})$", cells[0])
96
+ if not d:
97
+ continue
98
+ m1, d1 = _MONTHS.get(d.group(1).lower()), int(d.group(2))
99
+ m2, d2 = _MONTHS.get((d.group(3) or d.group(1)).lower()), int(d.group(4))
100
+ if not m1 or not m2:
101
+ continue
102
+ start = dt.date(year, m1, d1)
103
+ end = dt.date(year + (1 if m2 < m1 else 0), m2, d2)
104
+ name = cells[1]
105
+ tags = next((v for k, v in FEST_TAGS.items() if k.lower() in name.lower()), [])
106
+ events.append(Event(name, "fest", start, end, tags))
107
+
108
+ uniq = {(e.name, e.start): e for e in events}
109
+ return sorted(uniq.values(), key=lambda e: e.start)
110
+
111
+
112
+ def events() -> list[Event]:
113
+ cached = _cache.get("events", 7 * DAY)
114
+ if cached is not None:
115
+ return [Event.from_json(e) for e in cached]
116
+ try:
117
+ r = SESSION.get(URL, timeout=15)
118
+ evs = parse(r.text) if r.status_code == 200 else []
119
+ except Exception: # noqa: BLE001 - the calendar is a nice-to-have
120
+ evs = []
121
+ if evs:
122
+ _cache.set("events", [e.to_json() for e in evs])
123
+ return evs
124
+
125
+
126
+ def current_sale(today: dt.date | None = None, evs: list[Event] | None = None) -> Event | None:
127
+ today = today or dt.date.today()
128
+ return next((e for e in (evs if evs is not None else events())
129
+ if e.kind == "sale" and e.start <= today <= e.end), None)
130
+
131
+
132
+ def next_sale(today: dt.date | None = None, within_days: int = 45, evs: list[Event] | None = None) -> Event | None:
133
+ today = today or dt.date.today()
134
+ return next((e for e in (evs if evs is not None else events())
135
+ if e.kind == "sale" and today < e.start <= today + dt.timedelta(days=within_days)), None)
136
+
137
+
138
+ def fests_for(taste_tags: list[str], today: dt.date | None = None, within_days: int = 60,
139
+ evs: list[Event] | None = None) -> list[Event]:
140
+ """Upcoming or running themed fests whose tags overlap your strongest taste tags."""
141
+ today = today or dt.date.today()
142
+ want = {t.lower() for t in taste_tags}
143
+ return [e for e in (evs if evs is not None else events())
144
+ if e.kind == "fest" and e.end >= today and e.start <= today + dt.timedelta(days=within_days)
145
+ and want & {t.lower() for t in e.tags}]