hpde-analytics-cli 6.0.7__tar.gz → 6.0.9__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 (34) hide show
  1. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/PKG-INFO +46 -7
  2. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/README.md +44 -6
  3. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/auth/oauth.py +13 -3
  4. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/main.py +12 -7
  5. hpde_analytics_cli-6.0.9/hpde_analytics_cli/paths.py +123 -0
  6. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli.egg-info/PKG-INFO +46 -7
  7. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli.egg-info/SOURCES.txt +2 -0
  8. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli.egg-info/requires.txt +1 -0
  9. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/pyproject.toml +2 -1
  10. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/tests/test_main.py +35 -18
  11. hpde_analytics_cli-6.0.9/tests/test_paths.py +168 -0
  12. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/LICENSE +0 -0
  13. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/__init__.py +0 -0
  14. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/api/__init__.py +0 -0
  15. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/api/client.py +0 -0
  16. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/auth/__init__.py +0 -0
  17. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/auth/credentials.py +0 -0
  18. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/integrations/__init__.py +0 -0
  19. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/integrations/email_populator.py +0 -0
  20. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/integrations/google_sheets.py +0 -0
  21. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/utils/__init__.py +0 -0
  22. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/utils/data_export.py +0 -0
  23. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/utils/field_discovery.py +0 -0
  24. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli/utils/report_generator.py +0 -0
  25. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli.egg-info/dependency_links.txt +0 -0
  26. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli.egg-info/entry_points.txt +0 -0
  27. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/hpde_analytics_cli.egg-info/top_level.txt +0 -0
  28. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/setup.cfg +0 -0
  29. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/tests/test_client.py +0 -0
  30. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/tests/test_credentials.py +0 -0
  31. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/tests/test_data_export.py +0 -0
  32. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/tests/test_email_populator.py +0 -0
  33. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/tests/test_google_sheets.py +0 -0
  34. {hpde_analytics_cli-6.0.7 → hpde_analytics_cli-6.0.9}/tests/test_report_generator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hpde-analytics-cli
3
- Version: 6.0.7
3
+ Version: 6.0.9
4
4
  Summary: Data analysis and reporting tool for HPDE and Time Trials programs
5
5
  Author-email: Kevin Homan <homan13@gmail.com>
6
6
  License: MIT
@@ -32,6 +32,7 @@ Requires-Dist: openpyxl>=3.1.0
32
32
  Requires-Dist: keyring>=24.0.0
33
33
  Requires-Dist: gspread>=6.0.0
34
34
  Requires-Dist: google-auth>=2.22.0
35
+ Requires-Dist: platformdirs>=4.0.0
35
36
  Provides-Extra: dev
36
37
  Requires-Dist: pytest>=7.0.0; extra == "dev"
37
38
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -94,16 +95,25 @@ pip install hpde-analytics-cli
94
95
  This stores your credentials securely in your system's keyring (macOS Keychain, Windows Credential Locker, or Linux Secret Service).
95
96
 
96
97
  **Option B: Environment file (Fallback)**
97
- ```bash
98
- cp .env.example .env
99
- ```
100
- Edit `.env` and add your MotorsportsReg OAuth credentials:
98
+
99
+ Create a `.env` file in the directory you run the tool from:
101
100
  ```
102
101
  MSR_CONSUMER_KEY=your_consumer_key_here
103
102
  MSR_CONSUMER_SECRET=your_consumer_secret_here
104
103
  MSR_BASE_URL=https://api.motorsportreg.com
105
104
  MSR_CALLBACK_PORT=8089
106
105
  ```
106
+ (Working from a clone, `cp .env.example .env` gives you a starting point.)
107
+
108
+ Two locations are searched, in this order:
109
+
110
+ | Location | Purpose |
111
+ | --- | --- |
112
+ | `.env` in the working directory, or any parent | Per-project settings |
113
+ | `.env` in the user config directory (below) | Your defaults across all directories |
114
+
115
+ The project file wins where both set the same key; the user file supplies anything it
116
+ leaves out.
107
117
 
108
118
  2. Check credential status (optional):
109
119
  ```bash
@@ -198,6 +208,7 @@ hpde-analytics-cli/
198
208
  ├── hpde_analytics_cli/
199
209
  │ ├── __init__.py
200
210
  │ ├── main.py # CLI entry point
211
+ │ ├── paths.py # Config, token and output path resolution
201
212
  │ ├── auth/
202
213
  │ │ ├── __init__.py
203
214
  │ │ ├── credentials.py # Secure credential management
@@ -211,10 +222,35 @@ hpde-analytics-cli/
211
222
  │ ├── field_discovery.py # API field enumeration
212
223
  │ └── report_generator.py # Excel report generation
213
224
  ├── output/ # Export and report output (not in version control)
214
- ├── tokens/ # OAuth access tokens (not in version control)
215
225
  └── tests/ # Unit tests
216
226
  ```
217
227
 
228
+ ## File Locations
229
+
230
+ Exports and reports are written relative to **the directory you run the command from**, so
231
+ the tool behaves the same whether installed from PyPI or run from a clone. OAuth tokens go
232
+ to a per-user config directory, so they survive upgrades and stay out of your project
233
+ folders.
234
+
235
+ | What | Location |
236
+ | --- | --- |
237
+ | Exports and reports | `./output/` in the current directory (override with `--output-dir`) |
238
+ | Field inventory | `./output/field_inventory.json` (override with `--output`) |
239
+ | OAuth access token | user config directory, below |
240
+ | `.env` | current directory or any parent, then the user config directory |
241
+
242
+ The user config directory is the native location for your platform:
243
+
244
+ | Platform | Path |
245
+ | --- | --- |
246
+ | Linux | `~/.config/hpde-analytics-cli/` |
247
+ | macOS | `~/Library/Application Support/hpde-analytics-cli/` |
248
+ | Windows | `%LOCALAPPDATA%\hpde-analytics-cli\` |
249
+
250
+ Set `HPDE_CONFIG_DIR` to override it. The token file is written readable only by you
251
+ (mode `0600` on POSIX systems). A token left by version 6.0.7 or earlier is moved to the
252
+ new location automatically on first run, so you will not need to re-authenticate.
253
+
218
254
  ## Export Structure
219
255
 
220
256
  Each export creates two data sets:
@@ -313,10 +349,13 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
313
349
  ## Security Notes
314
350
 
315
351
  **Important:**
316
- - Never commit `.env` or `tokens/` directory to version control
352
+ - Never commit a `.env` file to version control
317
353
  - OAuth consumer secrets and access tokens must be kept confidential
318
354
  - The `.gitignore` file is configured to exclude these sensitive files automatically
319
355
  - Use `hpde-analytics-cli --configure` to store credentials securely in your system keyring
356
+ - Access tokens live outside the project in the user config directory, written mode `0600`
357
+ - Exported data contains registrant names and email addresses; `output/` is gitignored, but
358
+ take the same care with it that you would with any roster
320
359
 
321
360
  ## Support
322
361
 
@@ -48,16 +48,25 @@ pip install hpde-analytics-cli
48
48
  This stores your credentials securely in your system's keyring (macOS Keychain, Windows Credential Locker, or Linux Secret Service).
49
49
 
50
50
  **Option B: Environment file (Fallback)**
51
- ```bash
52
- cp .env.example .env
53
- ```
54
- Edit `.env` and add your MotorsportsReg OAuth credentials:
51
+
52
+ Create a `.env` file in the directory you run the tool from:
55
53
  ```
56
54
  MSR_CONSUMER_KEY=your_consumer_key_here
57
55
  MSR_CONSUMER_SECRET=your_consumer_secret_here
58
56
  MSR_BASE_URL=https://api.motorsportreg.com
59
57
  MSR_CALLBACK_PORT=8089
60
58
  ```
59
+ (Working from a clone, `cp .env.example .env` gives you a starting point.)
60
+
61
+ Two locations are searched, in this order:
62
+
63
+ | Location | Purpose |
64
+ | --- | --- |
65
+ | `.env` in the working directory, or any parent | Per-project settings |
66
+ | `.env` in the user config directory (below) | Your defaults across all directories |
67
+
68
+ The project file wins where both set the same key; the user file supplies anything it
69
+ leaves out.
61
70
 
62
71
  2. Check credential status (optional):
63
72
  ```bash
@@ -152,6 +161,7 @@ hpde-analytics-cli/
152
161
  ├── hpde_analytics_cli/
153
162
  │ ├── __init__.py
154
163
  │ ├── main.py # CLI entry point
164
+ │ ├── paths.py # Config, token and output path resolution
155
165
  │ ├── auth/
156
166
  │ │ ├── __init__.py
157
167
  │ │ ├── credentials.py # Secure credential management
@@ -165,10 +175,35 @@ hpde-analytics-cli/
165
175
  │ ├── field_discovery.py # API field enumeration
166
176
  │ └── report_generator.py # Excel report generation
167
177
  ├── output/ # Export and report output (not in version control)
168
- ├── tokens/ # OAuth access tokens (not in version control)
169
178
  └── tests/ # Unit tests
170
179
  ```
171
180
 
181
+ ## File Locations
182
+
183
+ Exports and reports are written relative to **the directory you run the command from**, so
184
+ the tool behaves the same whether installed from PyPI or run from a clone. OAuth tokens go
185
+ to a per-user config directory, so they survive upgrades and stay out of your project
186
+ folders.
187
+
188
+ | What | Location |
189
+ | --- | --- |
190
+ | Exports and reports | `./output/` in the current directory (override with `--output-dir`) |
191
+ | Field inventory | `./output/field_inventory.json` (override with `--output`) |
192
+ | OAuth access token | user config directory, below |
193
+ | `.env` | current directory or any parent, then the user config directory |
194
+
195
+ The user config directory is the native location for your platform:
196
+
197
+ | Platform | Path |
198
+ | --- | --- |
199
+ | Linux | `~/.config/hpde-analytics-cli/` |
200
+ | macOS | `~/Library/Application Support/hpde-analytics-cli/` |
201
+ | Windows | `%LOCALAPPDATA%\hpde-analytics-cli\` |
202
+
203
+ Set `HPDE_CONFIG_DIR` to override it. The token file is written readable only by you
204
+ (mode `0600` on POSIX systems). A token left by version 6.0.7 or earlier is moved to the
205
+ new location automatically on first run, so you will not need to re-authenticate.
206
+
172
207
  ## Export Structure
173
208
 
174
209
  Each export creates two data sets:
@@ -267,10 +302,13 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
267
302
  ## Security Notes
268
303
 
269
304
  **Important:**
270
- - Never commit `.env` or `tokens/` directory to version control
305
+ - Never commit a `.env` file to version control
271
306
  - OAuth consumer secrets and access tokens must be kept confidential
272
307
  - The `.gitignore` file is configured to exclude these sensitive files automatically
273
308
  - Use `hpde-analytics-cli --configure` to store credentials securely in your system keyring
309
+ - Access tokens live outside the project in the user config directory, written mode `0600`
310
+ - Exported data contains registrant names and email addresses; `output/` is gitignored, but
311
+ take the same care with it that you would with any roster
274
312
 
275
313
  ## Support
276
314
 
@@ -19,6 +19,8 @@ from urllib.parse import parse_qs, urlparse
19
19
 
20
20
  from requests_oauthlib import OAuth1Session
21
21
 
22
+ from hpde_analytics_cli import paths
23
+
22
24
  # Default callback port for local OAuth flow
23
25
  DEFAULT_CALLBACK_PORT = 8089
24
26
 
@@ -147,8 +149,12 @@ class MSROAuth:
147
149
 
148
150
  # Token storage
149
151
  if token_file is None:
150
- project_root = Path(__file__).parent.parent.parent
151
- self.token_file = project_root / "tokens" / "access_token.json"
152
+ self.token_file = paths.token_file()
153
+ # Only when using the default location: an explicit token_file (tests, or a
154
+ # caller managing its own state) must never have a stray file moved into it.
155
+ migrated_from = paths.migrate_legacy_token(self.token_file)
156
+ if migrated_from:
157
+ print(f"Moved access token from {migrated_from} to {self.token_file}")
152
158
  else:
153
159
  self.token_file = Path(token_file)
154
160
 
@@ -190,8 +196,9 @@ class MSROAuth:
190
196
  return False
191
197
 
192
198
  def _save_tokens(self) -> None:
193
- """Save access tokens to file."""
199
+ """Save access tokens to file, readable only by the owner."""
194
200
  self.token_file.parent.mkdir(parents=True, exist_ok=True)
201
+ paths.restrict_permissions(self.token_file.parent)
195
202
 
196
203
  data = {
197
204
  "access_token": self.access_token,
@@ -203,6 +210,9 @@ class MSROAuth:
203
210
  with open(self.token_file, "w") as f:
204
211
  json.dump(data, f, indent=2)
205
212
 
213
+ # The file holds the access token and its secret; keep it off other accounts.
214
+ paths.restrict_permissions(self.token_file)
215
+
206
216
  print(f"Access tokens saved to {self.token_file}")
207
217
 
208
218
  def has_valid_tokens(self) -> bool:
@@ -14,7 +14,7 @@ from typing import Any, Dict, Optional
14
14
 
15
15
  from dotenv import load_dotenv
16
16
 
17
- from hpde_analytics_cli import __version__
17
+ from hpde_analytics_cli import __version__, paths
18
18
  from hpde_analytics_cli.api.client import create_client_from_oauth
19
19
  from hpde_analytics_cli.auth.credentials import CredentialManager
20
20
  from hpde_analytics_cli.auth.oauth import MSROAuth, create_oauth_from_env
@@ -104,7 +104,7 @@ def handle_discover(oauth, args) -> None:
104
104
  client = create_client_from_oauth(oauth, organization_id=args.org_id)
105
105
  api_data = fetch_api_data(client, event_id=args.event_id, verbose=args.verbose)
106
106
 
107
- output_path = Path(__file__).parent.parent / args.output
107
+ output_path = Path(args.output)
108
108
  run_field_discovery(api_data, output_path=str(output_path))
109
109
 
110
110
 
@@ -167,7 +167,7 @@ def handle_export(oauth, args) -> None:
167
167
  if args.output_dir:
168
168
  output_dir = Path(args.output_dir)
169
169
  else:
170
- output_dir = Path(__file__).parent.parent / "output"
170
+ output_dir = paths.default_output_dir()
171
171
 
172
172
  exporter = DataExporter(output_dir=str(output_dir), name=args.name)
173
173
  exported_files = exporter.export_all_data(
@@ -284,14 +284,19 @@ def handle_full_flow(oauth, args) -> None:
284
284
  client = create_client_from_oauth(oauth, organization_id=args.org_id)
285
285
  api_data = fetch_api_data(client, event_id=args.event_id, verbose=args.verbose)
286
286
 
287
- output_path = Path(__file__).parent.parent / args.output
287
+ output_path = Path(args.output)
288
288
  run_field_discovery(api_data, output_path=str(output_path))
289
289
 
290
290
 
291
291
  def load_environment(verbose: bool = False) -> None:
292
- """Load environment variables from .env file if it exists."""
293
- env_path = Path(__file__).parent.parent / ".env"
294
- if env_path.exists():
292
+ """
293
+ Load environment variables from .env files.
294
+
295
+ Loads the project .env (searched upward from the working directory) before the
296
+ per-user one. load_dotenv leaves already-set keys alone, so the project file wins and
297
+ the user file fills in whatever it omits.
298
+ """
299
+ for env_path in paths.find_env_files():
295
300
  load_dotenv(env_path)
296
301
  if verbose:
297
302
  print(f"Loaded environment from {env_path}")
@@ -0,0 +1,123 @@
1
+ """
2
+ Filesystem path resolution.
3
+
4
+ Single owner for every user-facing path the CLI reads or writes.
5
+
6
+ Paths must never be derived from ``__file__``. For an installed package that resolves to
7
+ ``site-packages``, which previously meant the user's ``.env`` was never read, OAuth tokens
8
+ were written where the next upgrade destroys them, and exported registration data landed
9
+ in a library directory. See CODE_REVIEW.md #2.
10
+ """
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from typing import List, Optional
15
+
16
+ from dotenv import find_dotenv
17
+ from platformdirs import user_config_dir
18
+
19
+ APP_NAME = "hpde-analytics-cli"
20
+
21
+ #: Overrides the per-user config directory. Mainly for tests and sandboxed front-ends.
22
+ ENV_CONFIG_DIR = "HPDE_CONFIG_DIR"
23
+
24
+ ENV_FILENAME = ".env"
25
+ OUTPUT_DIRNAME = "output"
26
+ TOKEN_FILENAME = "access_token.json"
27
+ LEGACY_TOKEN_DIRNAME = "tokens"
28
+
29
+
30
+ def config_dir() -> Path:
31
+ """
32
+ Per-user configuration directory.
33
+
34
+ Honours ``HPDE_CONFIG_DIR`` if set, otherwise the platform-native location:
35
+ ``~/.config/hpde-analytics-cli`` on Linux, ``~/Library/Application Support/...`` on
36
+ macOS, ``%LOCALAPPDATA%\\...`` on Windows.
37
+ """
38
+ override = os.environ.get(ENV_CONFIG_DIR)
39
+ if override:
40
+ return Path(override).expanduser()
41
+ return Path(user_config_dir(APP_NAME))
42
+
43
+
44
+ def token_file() -> Path:
45
+ """Path to the stored OAuth access token."""
46
+ return config_dir() / TOKEN_FILENAME
47
+
48
+
49
+ def default_output_dir() -> Path:
50
+ """Default directory for exports and reports, relative to where the user is."""
51
+ return Path.cwd() / OUTPUT_DIRNAME
52
+
53
+
54
+ def find_env_files() -> List[Path]:
55
+ """
56
+ ``.env`` files to load, in precedence order.
57
+
58
+ The project file comes first: ``load_dotenv`` does not overwrite keys that are already
59
+ set, so loading in this order gives a project-local ``.env`` precedence over the
60
+ per-user one, which then supplies anything the project file left out.
61
+ """
62
+ found: List[Path] = []
63
+
64
+ project_env = find_dotenv(ENV_FILENAME, usecwd=True)
65
+ if project_env:
66
+ found.append(Path(project_env))
67
+
68
+ user_env = config_dir() / ENV_FILENAME
69
+ if user_env.exists() and user_env not in found:
70
+ found.append(user_env)
71
+
72
+ return found
73
+
74
+
75
+ def legacy_token_file() -> Path:
76
+ """
77
+ Where versions up to 6.0.7 stored the access token.
78
+
79
+ Derived from ``__file__`` the way the old code did, so it resolves to the repository
80
+ root for a source checkout and to ``site-packages`` for an installed package.
81
+ """
82
+ return Path(__file__).parent.parent / LEGACY_TOKEN_DIRNAME / TOKEN_FILENAME
83
+
84
+
85
+ def migrate_legacy_token(destination: Path) -> Optional[Path]:
86
+ """
87
+ Move a token left by an older version into ``destination``.
88
+
89
+ Does nothing if the destination already exists or there is nothing to move, so it is
90
+ safe to call on every startup.
91
+
92
+ Returns:
93
+ The path moved from, or None if nothing was migrated.
94
+ """
95
+ if destination.exists():
96
+ return None
97
+
98
+ legacy = legacy_token_file()
99
+ if not legacy.is_file():
100
+ return None
101
+
102
+ try:
103
+ destination.parent.mkdir(parents=True, exist_ok=True)
104
+ destination.write_bytes(legacy.read_bytes())
105
+ restrict_permissions(destination)
106
+ legacy.unlink()
107
+ except OSError:
108
+ return None
109
+
110
+ return legacy
111
+
112
+
113
+ def restrict_permissions(path: Path) -> None:
114
+ """
115
+ Restrict a file to the owner (0600), or a directory to 0700.
116
+
117
+ Best effort: POSIX permissions do not map onto Windows ACLs, so failures are ignored
118
+ rather than breaking an otherwise working auth flow.
119
+ """
120
+ try:
121
+ os.chmod(path, 0o700 if path.is_dir() else 0o600)
122
+ except OSError:
123
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hpde-analytics-cli
3
- Version: 6.0.7
3
+ Version: 6.0.9
4
4
  Summary: Data analysis and reporting tool for HPDE and Time Trials programs
5
5
  Author-email: Kevin Homan <homan13@gmail.com>
6
6
  License: MIT
@@ -32,6 +32,7 @@ Requires-Dist: openpyxl>=3.1.0
32
32
  Requires-Dist: keyring>=24.0.0
33
33
  Requires-Dist: gspread>=6.0.0
34
34
  Requires-Dist: google-auth>=2.22.0
35
+ Requires-Dist: platformdirs>=4.0.0
35
36
  Provides-Extra: dev
36
37
  Requires-Dist: pytest>=7.0.0; extra == "dev"
37
38
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -94,16 +95,25 @@ pip install hpde-analytics-cli
94
95
  This stores your credentials securely in your system's keyring (macOS Keychain, Windows Credential Locker, or Linux Secret Service).
95
96
 
96
97
  **Option B: Environment file (Fallback)**
97
- ```bash
98
- cp .env.example .env
99
- ```
100
- Edit `.env` and add your MotorsportsReg OAuth credentials:
98
+
99
+ Create a `.env` file in the directory you run the tool from:
101
100
  ```
102
101
  MSR_CONSUMER_KEY=your_consumer_key_here
103
102
  MSR_CONSUMER_SECRET=your_consumer_secret_here
104
103
  MSR_BASE_URL=https://api.motorsportreg.com
105
104
  MSR_CALLBACK_PORT=8089
106
105
  ```
106
+ (Working from a clone, `cp .env.example .env` gives you a starting point.)
107
+
108
+ Two locations are searched, in this order:
109
+
110
+ | Location | Purpose |
111
+ | --- | --- |
112
+ | `.env` in the working directory, or any parent | Per-project settings |
113
+ | `.env` in the user config directory (below) | Your defaults across all directories |
114
+
115
+ The project file wins where both set the same key; the user file supplies anything it
116
+ leaves out.
107
117
 
108
118
  2. Check credential status (optional):
109
119
  ```bash
@@ -198,6 +208,7 @@ hpde-analytics-cli/
198
208
  ├── hpde_analytics_cli/
199
209
  │ ├── __init__.py
200
210
  │ ├── main.py # CLI entry point
211
+ │ ├── paths.py # Config, token and output path resolution
201
212
  │ ├── auth/
202
213
  │ │ ├── __init__.py
203
214
  │ │ ├── credentials.py # Secure credential management
@@ -211,10 +222,35 @@ hpde-analytics-cli/
211
222
  │ ├── field_discovery.py # API field enumeration
212
223
  │ └── report_generator.py # Excel report generation
213
224
  ├── output/ # Export and report output (not in version control)
214
- ├── tokens/ # OAuth access tokens (not in version control)
215
225
  └── tests/ # Unit tests
216
226
  ```
217
227
 
228
+ ## File Locations
229
+
230
+ Exports and reports are written relative to **the directory you run the command from**, so
231
+ the tool behaves the same whether installed from PyPI or run from a clone. OAuth tokens go
232
+ to a per-user config directory, so they survive upgrades and stay out of your project
233
+ folders.
234
+
235
+ | What | Location |
236
+ | --- | --- |
237
+ | Exports and reports | `./output/` in the current directory (override with `--output-dir`) |
238
+ | Field inventory | `./output/field_inventory.json` (override with `--output`) |
239
+ | OAuth access token | user config directory, below |
240
+ | `.env` | current directory or any parent, then the user config directory |
241
+
242
+ The user config directory is the native location for your platform:
243
+
244
+ | Platform | Path |
245
+ | --- | --- |
246
+ | Linux | `~/.config/hpde-analytics-cli/` |
247
+ | macOS | `~/Library/Application Support/hpde-analytics-cli/` |
248
+ | Windows | `%LOCALAPPDATA%\hpde-analytics-cli\` |
249
+
250
+ Set `HPDE_CONFIG_DIR` to override it. The token file is written readable only by you
251
+ (mode `0600` on POSIX systems). A token left by version 6.0.7 or earlier is moved to the
252
+ new location automatically on first run, so you will not need to re-authenticate.
253
+
218
254
  ## Export Structure
219
255
 
220
256
  Each export creates two data sets:
@@ -313,10 +349,13 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
313
349
  ## Security Notes
314
350
 
315
351
  **Important:**
316
- - Never commit `.env` or `tokens/` directory to version control
352
+ - Never commit a `.env` file to version control
317
353
  - OAuth consumer secrets and access tokens must be kept confidential
318
354
  - The `.gitignore` file is configured to exclude these sensitive files automatically
319
355
  - Use `hpde-analytics-cli --configure` to store credentials securely in your system keyring
356
+ - Access tokens live outside the project in the user config directory, written mode `0600`
357
+ - Exported data contains registrant names and email addresses; `output/` is gitignored, but
358
+ take the same care with it that you would with any roster
320
359
 
321
360
  ## Support
322
361
 
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  hpde_analytics_cli/__init__.py
5
5
  hpde_analytics_cli/main.py
6
+ hpde_analytics_cli/paths.py
6
7
  hpde_analytics_cli.egg-info/PKG-INFO
7
8
  hpde_analytics_cli.egg-info/SOURCES.txt
8
9
  hpde_analytics_cli.egg-info/dependency_links.txt
@@ -27,4 +28,5 @@ tests/test_data_export.py
27
28
  tests/test_email_populator.py
28
29
  tests/test_google_sheets.py
29
30
  tests/test_main.py
31
+ tests/test_paths.py
30
32
  tests/test_report_generator.py
@@ -5,6 +5,7 @@ openpyxl>=3.1.0
5
5
  keyring>=24.0.0
6
6
  gspread>=6.0.0
7
7
  google-auth>=2.22.0
8
+ platformdirs>=4.0.0
8
9
 
9
10
  [dev]
10
11
  pytest>=7.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hpde-analytics-cli"
7
- version = "6.0.7" # x-release-please-version
7
+ version = "6.0.9" # x-release-please-version
8
8
  description = "Data analysis and reporting tool for HPDE and Time Trials programs"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -43,6 +43,7 @@ dependencies = [
43
43
  "keyring>=24.0.0",
44
44
  "gspread>=6.0.0",
45
45
  "google-auth>=2.22.0",
46
+ "platformdirs>=4.0.0",
46
47
  ]
47
48
 
48
49
  [project.optional-dependencies]
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
7
7
 
8
8
  import pytest
9
9
 
10
+ from hpde_analytics_cli import paths
10
11
  from hpde_analytics_cli.main import (
11
12
  create_parser,
12
13
  handle_credential_commands,
@@ -211,35 +212,51 @@ class TestHandleCredentialCommands:
211
212
  class TestLoadEnvironment:
212
213
  """Tests for load_environment function."""
213
214
 
215
+ @pytest.fixture(autouse=True)
216
+ def isolated_env(self, tmp_path, monkeypatch):
217
+ """Run in an empty directory with the user config dir pointed somewhere empty."""
218
+ work = tmp_path / "work"
219
+ work.mkdir()
220
+ monkeypatch.chdir(work)
221
+ monkeypatch.setenv(paths.ENV_CONFIG_DIR, str(tmp_path / "config"))
222
+ return work
223
+
214
224
  @patch("hpde_analytics_cli.main.load_dotenv")
215
- @patch("hpde_analytics_cli.main.Path")
216
- def test_loads_env_when_exists(self, mock_path, mock_load_dotenv):
217
- """Test that .env is loaded when it exists."""
218
- mock_env_path = MagicMock()
219
- mock_env_path.exists.return_value = True
220
- mock_path.return_value.__truediv__.return_value.__truediv__.return_value = mock_env_path
225
+ def test_loads_env_from_working_directory(self, mock_load_dotenv, isolated_env):
226
+ """A .env beside the user, not beside the installed package, is what gets read."""
227
+ env_file = isolated_env / ".env"
228
+ env_file.write_text("MSR_BASE_URL=https://example.test\n")
221
229
 
222
230
  load_environment(verbose=False)
223
231
 
224
232
  mock_load_dotenv.assert_called_once()
233
+ assert mock_load_dotenv.call_args[0][0].resolve() == env_file.resolve()
234
+
235
+ @patch("hpde_analytics_cli.main.load_dotenv")
236
+ def test_loads_project_env_before_user_env(self, mock_load_dotenv, isolated_env, tmp_path):
237
+ """Project .env is loaded first so its keys win; the user file fills the rest."""
238
+ project_env = isolated_env / ".env"
239
+ project_env.write_text("MSR_BASE_URL=https://project.test\n")
240
+ config = tmp_path / "config"
241
+ config.mkdir()
242
+ user_env = config / ".env"
243
+ user_env.write_text("MSR_CALLBACK_PORT=9999\n")
244
+
245
+ load_environment(verbose=False)
246
+
247
+ loaded = [call[0][0].resolve() for call in mock_load_dotenv.call_args_list]
248
+ assert loaded == [project_env.resolve(), user_env.resolve()]
225
249
 
226
250
  @patch("hpde_analytics_cli.main.load_dotenv")
227
- def test_skips_when_no_env(self, mock_load_dotenv, tmp_path):
228
- """Test that loading completes without error when .env doesn't exist."""
229
- # The function checks if .env exists before calling load_dotenv
230
- # This test verifies load_environment handles missing .env gracefully
231
- # Note: The actual .env check depends on the installed package location,
232
- # so we just verify the function doesn't raise errors
251
+ def test_skips_when_no_env(self, mock_load_dotenv, isolated_env):
252
+ """No .env anywhere is not an error, and nothing is loaded."""
233
253
  load_environment(verbose=False)
234
- # Function should complete without raising an error
254
+ mock_load_dotenv.assert_not_called()
235
255
 
236
256
  @patch("hpde_analytics_cli.main.load_dotenv")
237
- @patch("hpde_analytics_cli.main.Path")
238
- def test_verbose_output(self, mock_path, mock_load_dotenv, capsys):
257
+ def test_verbose_output(self, mock_load_dotenv, isolated_env, capsys):
239
258
  """Test verbose output when loading .env."""
240
- mock_env_path = MagicMock()
241
- mock_env_path.exists.return_value = True
242
- mock_path.return_value.__truediv__.return_value.__truediv__.return_value = mock_env_path
259
+ (isolated_env / ".env").write_text("MSR_BASE_URL=https://example.test\n")
243
260
 
244
261
  load_environment(verbose=True)
245
262
 
@@ -0,0 +1,168 @@
1
+ """
2
+ Tests for filesystem path resolution.
3
+
4
+ The property under test throughout is that no user-facing path depends on where the
5
+ package happens to be installed. See CODE_REVIEW.md #2.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import stat
11
+ import sys
12
+
13
+ import pytest
14
+
15
+ from hpde_analytics_cli import paths
16
+
17
+
18
+ @pytest.fixture
19
+ def config_dir(tmp_path, monkeypatch):
20
+ """Point the per-user config directory at a temporary directory."""
21
+ target = tmp_path / "config"
22
+ monkeypatch.setenv(paths.ENV_CONFIG_DIR, str(target))
23
+ return target
24
+
25
+
26
+ class TestConfigDir:
27
+ def test_honours_env_override(self, tmp_path, monkeypatch):
28
+ monkeypatch.setenv(paths.ENV_CONFIG_DIR, str(tmp_path / "custom"))
29
+ assert paths.config_dir() == tmp_path / "custom"
30
+
31
+ def test_expands_user_in_override(self, monkeypatch):
32
+ monkeypatch.setenv(paths.ENV_CONFIG_DIR, "~/somewhere")
33
+ assert "~" not in str(paths.config_dir())
34
+
35
+ def test_falls_back_to_platform_location(self, monkeypatch):
36
+ monkeypatch.delenv(paths.ENV_CONFIG_DIR, raising=False)
37
+ resolved = paths.config_dir()
38
+ assert resolved.is_absolute()
39
+ assert paths.APP_NAME in str(resolved)
40
+
41
+ def test_not_inside_site_packages(self, monkeypatch):
42
+ """The whole point of the module: never resolve into the install location."""
43
+ monkeypatch.delenv(paths.ENV_CONFIG_DIR, raising=False)
44
+ package_root = str(os.path.dirname(os.path.dirname(paths.__file__)))
45
+ assert not str(paths.config_dir()).startswith(package_root)
46
+
47
+
48
+ class TestTokenFile:
49
+ def test_lives_in_config_dir(self, config_dir):
50
+ assert paths.token_file() == config_dir / paths.TOKEN_FILENAME
51
+
52
+ def test_independent_of_working_directory(self, config_dir, tmp_path, monkeypatch):
53
+ monkeypatch.chdir(tmp_path)
54
+ first = paths.token_file()
55
+ sub = tmp_path / "elsewhere"
56
+ sub.mkdir()
57
+ monkeypatch.chdir(sub)
58
+ assert paths.token_file() == first
59
+
60
+
61
+ class TestDefaultOutputDir:
62
+ def test_relative_to_working_directory(self, tmp_path, monkeypatch):
63
+ monkeypatch.chdir(tmp_path)
64
+ assert paths.default_output_dir() == tmp_path / paths.OUTPUT_DIRNAME
65
+
66
+ def test_follows_working_directory(self, tmp_path, monkeypatch):
67
+ sub = tmp_path / "events"
68
+ sub.mkdir()
69
+ monkeypatch.chdir(sub)
70
+ assert paths.default_output_dir() == sub / paths.OUTPUT_DIRNAME
71
+
72
+
73
+ class TestFindEnvFiles:
74
+ def test_finds_project_env_in_cwd(self, tmp_path, monkeypatch, config_dir):
75
+ monkeypatch.chdir(tmp_path)
76
+ env = tmp_path / ".env"
77
+ env.write_text("MSR_BASE_URL=https://example.test\n")
78
+ assert env.resolve() in [p.resolve() for p in paths.find_env_files()]
79
+
80
+ def test_searches_upward_from_cwd(self, tmp_path, monkeypatch, config_dir):
81
+ env = tmp_path / ".env"
82
+ env.write_text("MSR_BASE_URL=https://example.test\n")
83
+ nested = tmp_path / "a" / "b"
84
+ nested.mkdir(parents=True)
85
+ monkeypatch.chdir(nested)
86
+ assert env.resolve() in [p.resolve() for p in paths.find_env_files()]
87
+
88
+ def test_includes_user_env(self, tmp_path, monkeypatch, config_dir):
89
+ monkeypatch.chdir(tmp_path)
90
+ config_dir.mkdir(parents=True)
91
+ user_env = config_dir / ".env"
92
+ user_env.write_text("MSR_BASE_URL=https://user.test\n")
93
+ assert user_env.resolve() in [p.resolve() for p in paths.find_env_files()]
94
+
95
+ def test_project_env_takes_precedence(self, tmp_path, monkeypatch, config_dir):
96
+ monkeypatch.chdir(tmp_path)
97
+ project_env = tmp_path / ".env"
98
+ project_env.write_text("MSR_BASE_URL=https://project.test\n")
99
+ config_dir.mkdir(parents=True)
100
+ (config_dir / ".env").write_text("MSR_BASE_URL=https://user.test\n")
101
+
102
+ found = paths.find_env_files()
103
+ assert len(found) == 2
104
+ assert found[0].resolve() == project_env.resolve()
105
+
106
+ def test_returns_empty_when_none_exist(self, tmp_path, monkeypatch, config_dir):
107
+ monkeypatch.chdir(tmp_path)
108
+ assert paths.find_env_files() == []
109
+
110
+
111
+ class TestMigrateLegacyToken:
112
+ def test_no_op_when_nothing_to_migrate(self, config_dir, monkeypatch, tmp_path):
113
+ monkeypatch.setattr(paths, "legacy_token_file", lambda: tmp_path / "absent.json")
114
+ assert paths.migrate_legacy_token(paths.token_file()) is None
115
+
116
+ def test_no_op_when_destination_exists(self, config_dir, monkeypatch, tmp_path):
117
+ legacy = tmp_path / "legacy.json"
118
+ legacy.write_text('{"access_token": "old"}')
119
+ monkeypatch.setattr(paths, "legacy_token_file", lambda: legacy)
120
+
121
+ destination = paths.token_file()
122
+ destination.parent.mkdir(parents=True)
123
+ destination.write_text('{"access_token": "current"}')
124
+
125
+ assert paths.migrate_legacy_token(destination) is None
126
+ assert json.loads(destination.read_text())["access_token"] == "current"
127
+ assert legacy.exists(), "must not consume the legacy file when it was not used"
128
+
129
+ def test_moves_token_and_removes_original(self, config_dir, monkeypatch, tmp_path):
130
+ legacy = tmp_path / "legacy.json"
131
+ legacy.write_text('{"access_token": "abc", "access_token_secret": "xyz"}')
132
+ monkeypatch.setattr(paths, "legacy_token_file", lambda: legacy)
133
+
134
+ destination = paths.token_file()
135
+ assert paths.migrate_legacy_token(destination) == legacy
136
+ assert json.loads(destination.read_text())["access_token"] == "abc"
137
+ assert not legacy.exists()
138
+
139
+ @pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions")
140
+ def test_migrated_token_is_owner_only(self, config_dir, monkeypatch, tmp_path):
141
+ legacy = tmp_path / "legacy.json"
142
+ legacy.write_text('{"access_token": "abc"}')
143
+ os.chmod(legacy, 0o644)
144
+ monkeypatch.setattr(paths, "legacy_token_file", lambda: legacy)
145
+
146
+ destination = paths.token_file()
147
+ paths.migrate_legacy_token(destination)
148
+ assert stat.S_IMODE(destination.stat().st_mode) == 0o600
149
+
150
+
151
+ class TestRestrictPermissions:
152
+ @pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions")
153
+ def test_file_becomes_owner_only(self, tmp_path):
154
+ target = tmp_path / "token.json"
155
+ target.write_text("{}")
156
+ os.chmod(target, 0o644)
157
+ paths.restrict_permissions(target)
158
+ assert stat.S_IMODE(target.stat().st_mode) == 0o600
159
+
160
+ @pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions")
161
+ def test_directory_becomes_owner_only(self, tmp_path):
162
+ target = tmp_path / "cfg"
163
+ target.mkdir(mode=0o755)
164
+ paths.restrict_permissions(target)
165
+ assert stat.S_IMODE(target.stat().st_mode) == 0o700
166
+
167
+ def test_missing_path_is_ignored(self, tmp_path):
168
+ paths.restrict_permissions(tmp_path / "does-not-exist")