hpde-analytics-cli 6.0.4__tar.gz → 6.0.8__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 (35) hide show
  1. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/PKG-INFO +59 -8
  2. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/README.md +57 -7
  3. hpde_analytics_cli-6.0.8/hpde_analytics_cli/__init__.py +10 -0
  4. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli/auth/oauth.py +13 -3
  5. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli/integrations/google_sheets.py +1 -0
  6. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli/main.py +18 -6
  7. hpde_analytics_cli-6.0.8/hpde_analytics_cli/paths.py +123 -0
  8. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli.egg-info/PKG-INFO +59 -8
  9. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli.egg-info/SOURCES.txt +2 -0
  10. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli.egg-info/requires.txt +1 -0
  11. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/pyproject.toml +100 -99
  12. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/tests/test_main.py +35 -18
  13. hpde_analytics_cli-6.0.8/tests/test_paths.py +168 -0
  14. hpde_analytics_cli-6.0.4/hpde_analytics_cli/__init__.py +0 -4
  15. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/LICENSE +0 -0
  16. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli/api/__init__.py +0 -0
  17. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli/api/client.py +0 -0
  18. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli/auth/__init__.py +0 -0
  19. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli/auth/credentials.py +0 -0
  20. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli/integrations/__init__.py +0 -0
  21. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli/integrations/email_populator.py +0 -0
  22. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli/utils/__init__.py +0 -0
  23. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli/utils/data_export.py +0 -0
  24. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli/utils/field_discovery.py +0 -0
  25. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli/utils/report_generator.py +0 -0
  26. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli.egg-info/dependency_links.txt +0 -0
  27. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli.egg-info/entry_points.txt +0 -0
  28. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/hpde_analytics_cli.egg-info/top_level.txt +0 -0
  29. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/setup.cfg +0 -0
  30. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/tests/test_client.py +0 -0
  31. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/tests/test_credentials.py +0 -0
  32. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/tests/test_data_export.py +0 -0
  33. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/tests/test_email_populator.py +0 -0
  34. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/tests/test_google_sheets.py +0 -0
  35. {hpde_analytics_cli-6.0.4 → hpde_analytics_cli-6.0.8}/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.4
3
+ Version: 6.0.8
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:
@@ -282,7 +318,19 @@ This project uses [Semantic Versioning (SemVer)](https://semver.org/):
282
318
  - **MINOR** version for new functionality in a backward compatible manner
283
319
  - **PATCH** version for backward compatible bug fixes
284
320
 
285
- **Current Version:** 2.0.0
321
+ Releases are automated by [release-please](https://github.com/googleapis/release-please)
322
+ from [Conventional Commit](https://www.conventionalcommits.org/) messages on `main`.
323
+ Version numbers are owned by that automation and should never be edited by hand.
324
+
325
+ To see the version you have installed:
326
+
327
+ ```bash
328
+ hpde-analytics-cli --version
329
+ ```
330
+
331
+ For the current release and full history, see
332
+ [Releases](https://github.com/Homan13/hpde-analytics-cli/releases) and
333
+ [CHANGELOG.md](CHANGELOG.md).
286
334
 
287
335
  ## Authors
288
336
 
@@ -301,10 +349,13 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
301
349
  ## Security Notes
302
350
 
303
351
  **Important:**
304
- - Never commit `.env` or `tokens/` directory to version control
352
+ - Never commit a `.env` file to version control
305
353
  - OAuth consumer secrets and access tokens must be kept confidential
306
354
  - The `.gitignore` file is configured to exclude these sensitive files automatically
307
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
308
359
 
309
360
  ## Support
310
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:
@@ -236,7 +271,19 @@ This project uses [Semantic Versioning (SemVer)](https://semver.org/):
236
271
  - **MINOR** version for new functionality in a backward compatible manner
237
272
  - **PATCH** version for backward compatible bug fixes
238
273
 
239
- **Current Version:** 2.0.0
274
+ Releases are automated by [release-please](https://github.com/googleapis/release-please)
275
+ from [Conventional Commit](https://www.conventionalcommits.org/) messages on `main`.
276
+ Version numbers are owned by that automation and should never be edited by hand.
277
+
278
+ To see the version you have installed:
279
+
280
+ ```bash
281
+ hpde-analytics-cli --version
282
+ ```
283
+
284
+ For the current release and full history, see
285
+ [Releases](https://github.com/Homan13/hpde-analytics-cli/releases) and
286
+ [CHANGELOG.md](CHANGELOG.md).
240
287
 
241
288
  ## Authors
242
289
 
@@ -255,10 +302,13 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
255
302
  ## Security Notes
256
303
 
257
304
  **Important:**
258
- - Never commit `.env` or `tokens/` directory to version control
305
+ - Never commit a `.env` file to version control
259
306
  - OAuth consumer secrets and access tokens must be kept confidential
260
307
  - The `.gitignore` file is configured to exclude these sensitive files automatically
261
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
262
312
 
263
313
  ## Support
264
314
 
@@ -0,0 +1,10 @@
1
+ """HPDE Analytics CLI: Event Registration API integration for HPDE and Time Trials programs."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ __app_name__ = "hpde-analytics-cli"
6
+
7
+ try:
8
+ __version__ = version(__app_name__)
9
+ except PackageNotFoundError: # pragma: no cover - source tree without an install
10
+ __version__ = "0.0.0+unknown"
@@ -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:
@@ -9,6 +9,7 @@ from typing import Any, List, Optional, Tuple
9
9
 
10
10
  try:
11
11
  import gspread
12
+
12
13
  GSPREAD_AVAILABLE = True
13
14
  except ImportError:
14
15
  GSPREAD_AVAILABLE = False
@@ -14,6 +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__, paths
17
18
  from hpde_analytics_cli.api.client import create_client_from_oauth
18
19
  from hpde_analytics_cli.auth.credentials import CredentialManager
19
20
  from hpde_analytics_cli.auth.oauth import MSROAuth, create_oauth_from_env
@@ -103,7 +104,7 @@ def handle_discover(oauth, args) -> None:
103
104
  client = create_client_from_oauth(oauth, organization_id=args.org_id)
104
105
  api_data = fetch_api_data(client, event_id=args.event_id, verbose=args.verbose)
105
106
 
106
- output_path = Path(__file__).parent.parent / args.output
107
+ output_path = Path(args.output)
107
108
  run_field_discovery(api_data, output_path=str(output_path))
108
109
 
109
110
 
@@ -166,7 +167,7 @@ def handle_export(oauth, args) -> None:
166
167
  if args.output_dir:
167
168
  output_dir = Path(args.output_dir)
168
169
  else:
169
- output_dir = Path(__file__).parent.parent / "output"
170
+ output_dir = paths.default_output_dir()
170
171
 
171
172
  exporter = DataExporter(output_dir=str(output_dir), name=args.name)
172
173
  exported_files = exporter.export_all_data(
@@ -283,14 +284,19 @@ def handle_full_flow(oauth, args) -> None:
283
284
  client = create_client_from_oauth(oauth, organization_id=args.org_id)
284
285
  api_data = fetch_api_data(client, event_id=args.event_id, verbose=args.verbose)
285
286
 
286
- output_path = Path(__file__).parent.parent / args.output
287
+ output_path = Path(args.output)
287
288
  run_field_discovery(api_data, output_path=str(output_path))
288
289
 
289
290
 
290
291
  def load_environment(verbose: bool = False) -> None:
291
- """Load environment variables from .env file if it exists."""
292
- env_path = Path(__file__).parent.parent / ".env"
293
- 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():
294
300
  load_dotenv(env_path)
295
301
  if verbose:
296
302
  print(f"Loaded environment from {env_path}")
@@ -327,6 +333,12 @@ Examples:
327
333
  """,
328
334
  )
329
335
 
336
+ parser.add_argument(
337
+ "--version",
338
+ action="version",
339
+ version=f"%(prog)s {__version__}",
340
+ help="Show the installed version and exit",
341
+ )
330
342
  parser.add_argument(
331
343
  "--configure",
332
344
  action="store_true",
@@ -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.4
3
+ Version: 6.0.8
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:
@@ -282,7 +318,19 @@ This project uses [Semantic Versioning (SemVer)](https://semver.org/):
282
318
  - **MINOR** version for new functionality in a backward compatible manner
283
319
  - **PATCH** version for backward compatible bug fixes
284
320
 
285
- **Current Version:** 2.0.0
321
+ Releases are automated by [release-please](https://github.com/googleapis/release-please)
322
+ from [Conventional Commit](https://www.conventionalcommits.org/) messages on `main`.
323
+ Version numbers are owned by that automation and should never be edited by hand.
324
+
325
+ To see the version you have installed:
326
+
327
+ ```bash
328
+ hpde-analytics-cli --version
329
+ ```
330
+
331
+ For the current release and full history, see
332
+ [Releases](https://github.com/Homan13/hpde-analytics-cli/releases) and
333
+ [CHANGELOG.md](CHANGELOG.md).
286
334
 
287
335
  ## Authors
288
336
 
@@ -301,10 +349,13 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
301
349
  ## Security Notes
302
350
 
303
351
  **Important:**
304
- - Never commit `.env` or `tokens/` directory to version control
352
+ - Never commit a `.env` file to version control
305
353
  - OAuth consumer secrets and access tokens must be kept confidential
306
354
  - The `.gitignore` file is configured to exclude these sensitive files automatically
307
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
308
359
 
309
360
  ## Support
310
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
@@ -1,99 +1,100 @@
1
- [build-system]
2
- requires = ["setuptools>=64.0", "wheel"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "hpde-analytics-cli"
7
- version = "6.0.4"
8
- description = "Data analysis and reporting tool for HPDE and Time Trials programs"
9
- readme = "README.md"
10
- license = {text = "MIT"}
11
- requires-python = ">=3.9"
12
- authors = [
13
- {name = "Kevin Homan", email = "homan13@gmail.com"}
14
- ]
15
- keywords = [
16
- "motorsports",
17
- "hpde",
18
- "time-trials",
19
- "motorsportreg",
20
- "analytics",
21
- "reporting"
22
- ]
23
- classifiers = [
24
- "Development Status :: 4 - Beta",
25
- "Environment :: Console",
26
- "Intended Audience :: End Users/Desktop",
27
- "License :: OSI Approved :: MIT License",
28
- "Operating System :: OS Independent",
29
- "Programming Language :: Python :: 3",
30
- "Programming Language :: Python :: 3.9",
31
- "Programming Language :: Python :: 3.10",
32
- "Programming Language :: Python :: 3.11",
33
- "Programming Language :: Python :: 3.12",
34
- "Programming Language :: Python :: 3.13",
35
- "Programming Language :: Python :: 3.14",
36
- "Topic :: Utilities",
37
- ]
38
- dependencies = [
39
- "requests>=2.28.0",
40
- "requests-oauthlib>=1.3.0",
41
- "python-dotenv>=1.0.0",
42
- "openpyxl>=3.1.0",
43
- "keyring>=24.0.0",
44
- "gspread>=6.0.0",
45
- "google-auth>=2.22.0",
46
- ]
47
-
48
- [project.optional-dependencies]
49
- dev = [
50
- "pytest>=7.0.0",
51
- "pytest-cov>=4.0.0",
52
- "black>=23.0.0",
53
- "flake8>=6.0.0",
54
- "mypy>=1.0.0",
55
- "isort>=5.12.0",
56
- "bandit>=1.7.0",
57
- "safety>=2.3.0",
58
- "pre-commit>=3.5.0",
59
- ]
60
-
61
- [project.urls]
62
- Homepage = "https://github.com/Homan13/hpde-analytics-cli"
63
- Documentation = "https://github.com/Homan13/hpde-analytics-cli#readme"
64
- Repository = "https://github.com/Homan13/hpde-analytics-cli.git"
65
- Issues = "https://github.com/Homan13/hpde-analytics-cli/issues"
66
-
67
- [project.scripts]
68
- hpde-analytics-cli = "hpde_analytics_cli.main:main"
69
-
70
- [tool.setuptools.packages.find]
71
- where = ["."]
72
- include = ["hpde_analytics_cli*"]
73
-
74
- [tool.black]
75
- line-length = 100
76
- target-version = ["py39", "py310", "py311", "py312", "py313"]
77
-
78
- [tool.pytest.ini_options]
79
- testpaths = ["tests"]
80
- python_files = ["test_*.py"]
81
- addopts = "-v --cov=hpde_analytics_cli --cov-report=term-missing"
82
-
83
- [tool.mypy]
84
- python_version = "3.9"
85
- warn_return_any = false
86
- warn_unused_configs = true
87
- ignore_missing_imports = true
88
- check_untyped_defs = false
89
- no_implicit_optional = false
90
-
91
- [tool.isort]
92
- profile = "black"
93
- line_length = 100
94
- known_first_party = ["hpde_analytics_cli"]
95
- skip = ["venv", ".venv", "build", "dist"]
96
-
97
- [tool.bandit]
98
- exclude_dirs = ["tests", "venv"]
99
- skips = ["B101"] # Skip assert warnings in tests
1
+ [build-system]
2
+ requires = ["setuptools>=64.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hpde-analytics-cli"
7
+ version = "6.0.8" # x-release-please-version
8
+ description = "Data analysis and reporting tool for HPDE and Time Trials programs"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ {name = "Kevin Homan", email = "homan13@gmail.com"}
14
+ ]
15
+ keywords = [
16
+ "motorsports",
17
+ "hpde",
18
+ "time-trials",
19
+ "motorsportreg",
20
+ "analytics",
21
+ "reporting"
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 4 - Beta",
25
+ "Environment :: Console",
26
+ "Intended Audience :: End Users/Desktop",
27
+ "License :: OSI Approved :: MIT License",
28
+ "Operating System :: OS Independent",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3.9",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Programming Language :: Python :: 3.13",
35
+ "Programming Language :: Python :: 3.14",
36
+ "Topic :: Utilities",
37
+ ]
38
+ dependencies = [
39
+ "requests>=2.28.0",
40
+ "requests-oauthlib>=1.3.0",
41
+ "python-dotenv>=1.0.0",
42
+ "openpyxl>=3.1.0",
43
+ "keyring>=24.0.0",
44
+ "gspread>=6.0.0",
45
+ "google-auth>=2.22.0",
46
+ "platformdirs>=4.0.0",
47
+ ]
48
+
49
+ [project.optional-dependencies]
50
+ dev = [
51
+ "pytest>=7.0.0",
52
+ "pytest-cov>=4.0.0",
53
+ "black>=23.0.0",
54
+ "flake8>=6.0.0",
55
+ "mypy>=1.0.0",
56
+ "isort>=5.12.0",
57
+ "bandit>=1.7.0",
58
+ "safety>=2.3.0",
59
+ "pre-commit>=3.5.0",
60
+ ]
61
+
62
+ [project.urls]
63
+ Homepage = "https://github.com/Homan13/hpde-analytics-cli"
64
+ Documentation = "https://github.com/Homan13/hpde-analytics-cli#readme"
65
+ Repository = "https://github.com/Homan13/hpde-analytics-cli.git"
66
+ Issues = "https://github.com/Homan13/hpde-analytics-cli/issues"
67
+
68
+ [project.scripts]
69
+ hpde-analytics-cli = "hpde_analytics_cli.main:main"
70
+
71
+ [tool.setuptools.packages.find]
72
+ where = ["."]
73
+ include = ["hpde_analytics_cli*"]
74
+
75
+ [tool.black]
76
+ line-length = 100
77
+ target-version = ["py39", "py310", "py311", "py312", "py313"]
78
+
79
+ [tool.pytest.ini_options]
80
+ testpaths = ["tests"]
81
+ python_files = ["test_*.py"]
82
+ addopts = "-v --cov=hpde_analytics_cli --cov-report=term-missing"
83
+
84
+ [tool.mypy]
85
+ python_version = "3.9"
86
+ warn_return_any = false
87
+ warn_unused_configs = true
88
+ ignore_missing_imports = true
89
+ check_untyped_defs = false
90
+ no_implicit_optional = false
91
+
92
+ [tool.isort]
93
+ profile = "black"
94
+ line_length = 100
95
+ known_first_party = ["hpde_analytics_cli"]
96
+ skip = ["venv", ".venv", "build", "dist"]
97
+
98
+ [tool.bandit]
99
+ exclude_dirs = ["tests", "venv"]
100
+ skips = ["B101"] # Skip assert warnings in tests
@@ -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")
@@ -1,4 +0,0 @@
1
- """HPDE Analytics CLI: Event Registration API integration for HPDE and Time Trials programs."""
2
-
3
- __version__ = "1.0.0"
4
- __app_name__ = "hpde-analytics-cli"