queryapigate 0.7.0__tar.gz → 0.7.1__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 (58) hide show
  1. {queryapigate-0.7.0/queryapigate.egg-info → queryapigate-0.7.1}/PKG-INFO +14 -6
  2. {queryapigate-0.7.0 → queryapigate-0.7.1}/README.md +13 -5
  3. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/__init__.py +1 -1
  4. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/app.py +4 -0
  5. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/examples.py +6 -2
  6. {queryapigate-0.7.0 → queryapigate-0.7.1/queryapigate.egg-info}/PKG-INFO +14 -6
  7. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_examples.py +40 -0
  8. {queryapigate-0.7.0 → queryapigate-0.7.1}/LICENSE +0 -0
  9. {queryapigate-0.7.0 → queryapigate-0.7.1}/pyproject.toml +0 -0
  10. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/__main__.py +0 -0
  11. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/apikeys.py +0 -0
  12. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/bundle.py +0 -0
  13. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/cache.py +0 -0
  14. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/cli.py +0 -0
  15. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/collection_admin.py +0 -0
  16. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/config.py +0 -0
  17. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/cors.py +0 -0
  18. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/definitions.py +0 -0
  19. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/engine.py +0 -0
  20. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/errors.py +0 -0
  21. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/formats.py +0 -0
  22. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/lib/h2-2.2.224.jar +0 -0
  23. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/logging_setup.py +0 -0
  24. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/metrics.py +0 -0
  25. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/openapi.py +0 -0
  26. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/params.py +0 -0
  27. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/pool.py +0 -0
  28. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/postman.py +0 -0
  29. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/ratelimit.py +0 -0
  30. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/runners.py +0 -0
  31. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/schema.py +0 -0
  32. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/sqltools.py +0 -0
  33. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/store.py +0 -0
  34. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate/ui.py +0 -0
  35. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate.egg-info/SOURCES.txt +0 -0
  36. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate.egg-info/dependency_links.txt +0 -0
  37. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate.egg-info/entry_points.txt +0 -0
  38. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate.egg-info/requires.txt +0 -0
  39. {queryapigate-0.7.0 → queryapigate-0.7.1}/queryapigate.egg-info/top_level.txt +0 -0
  40. {queryapigate-0.7.0 → queryapigate-0.7.1}/setup.cfg +0 -0
  41. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_api_keys.py +0 -0
  42. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_audit_log.py +0 -0
  43. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_cli.py +0 -0
  44. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_collection_bundle.py +0 -0
  45. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_collection_postman.py +0 -0
  46. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_collections.py +0 -0
  47. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_connection_encryption.py +0 -0
  48. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_cors_and_rate_limit.py +0 -0
  49. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_duckdb.py +0 -0
  50. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_integration.py +0 -0
  51. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_jdbc.py +0 -0
  52. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_observability.py +0 -0
  53. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_params.py +0 -0
  54. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_queryapigate.py +0 -0
  55. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_release_check.py +0 -0
  56. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_response_cache.py +0 -0
  57. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_roles.py +0 -0
  58. {queryapigate-0.7.0 → queryapigate-0.7.1}/tests/test_sql_guard_fuzz.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queryapigate
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: Turn SQL into a REST API: run queries against MySQL, PostgreSQL, ClickHouse, SQLite or H2 over HTTP and get JSON, CSV, XML, YAML or XLSX back.
5
5
  Author-email: Anantha Raju C <arcswdev@gmail.com>
6
6
  License-Expression: FSL-1.1-MIT
@@ -73,6 +73,12 @@ Dynamic: license-file
73
73
 
74
74
  [**Full documentation**](https://AnanthaRajuC.github.io/QueryAPIGate/)
75
75
 
76
+ <p align="center">
77
+ <a href="https://www.youtube.com/watch?v=aiVbAkA2LS8">
78
+ <img src="https://raw.githubusercontent.com/AnanthaRajuC/QueryAPIGate/main/documentation/screenshots/demo-thumbnail.png" alt="Watch the 60-second demo: saved SQL queries as REST endpoints, scoped API keys, and the Move preview showing which keys gain or lose access" width="640">
79
+ </a>
80
+ </p>
81
+
76
82
  **QueryAPIGate** is a self-hosted, single Flask service that runs SQL against your databases and returns the results as
77
83
  JSON, NDJSON, XML, YAML, CSV, TSV or Excel. Save a query once and it becomes a versioned endpoint with typed,
78
84
  injection-safe parameters and run history - without writing a controller, a repository layer, pagination, auth or
@@ -610,7 +616,7 @@ instead of it - a request has to pass both. See [Per-key rate limiting](document
610
616
  Every release is published to GitHub Container Registry for `linux/amd64` and `linux/arm64`:
611
617
 
612
618
  ~~~bash
613
- docker run -p 5000:5000 -v "$PWD/data:/data" -e QUERYAPIGATE_API_KEY=change-me ghcr.io/anantharajuc/queryapigate:latest
619
+ docker run -p 5000:5000 -v queryapigate-data:/data -e QUERYAPIGATE_API_KEY=change-me ghcr.io/anantharajuc/queryapigate:latest
614
620
  ~~~
615
621
 
616
622
  | Tag | Contents |
@@ -619,7 +625,10 @@ docker run -p 5000:5000 -v "$PWD/data:/data" -e QUERYAPIGATE_API_KEY=change-me g
619
625
  | `X.Y.Z-h2`, `latest-h2` | The same plus Java and the H2 driver - also the variant to use for a generic `jdbc` connection (mount your vendor's jar) |
620
626
 
621
627
  The container keeps `db_connections.json` and `saved_sql/` in `/data` (create a starter with
622
- `docker run --rm -v "$PWD/data:/data" ghcr.io/anantharajuc/queryapigate queryapigate init`). It runs as a non-root user under
628
+ `docker run --rm -v queryapigate-data:/data ghcr.io/anantharajuc/queryapigate queryapigate init`). The named volume above works
629
+ out of the box. To use a folder on the host instead (`-v "$PWD/data:/data"`), create it yourself first (`mkdir data`) and make sure
630
+ it is writable by uid 1000, the container's user - a folder that Docker creates for you is owned by root, which the container
631
+ cannot write to (or run with `--user "$(id -u):$(id -g)"`). It runs as a non-root user under
623
632
  gunicorn with one worker (the files are protected by an in-process lock) and a health check on `/health`. Behind a
624
633
  reverse proxy or load balancer, set `QUERYAPIGATE_TRUST_PROXY=1`. To build it yourself:
625
634
  `docker build -t queryapigate .` (add `--build-arg WITH_H2=true` for H2).
@@ -734,9 +743,8 @@ In plain terms - the [LICENSE](https://github.com/AnanthaRajuC/QueryAPIGate/blob
734
743
  - **You may not** make it available to others in a commercial product or service that substitutes for QueryAPIGate or
735
744
  offers the same or substantially similar functionality (for example, selling it, or hosting it as a paid service).
736
745
  - **Each version becomes MIT** on the second anniversary of the date it was released, and can then be used under MIT terms.
737
- - **Versions up to and including 0.6.1** were released under the MIT license and remain available under it (the
738
- releases on PyPI: [`queryapigate`](https://pypi.org/project/queryapigate/) 0.5.0-0.6.1 and
739
- [`sql2api`](https://pypi.org/project/sql2api/) 0.1.0-0.4.0).
746
+ - **Versions up to and including 0.6.1** were released under the MIT license. They are no longer distributed (and the
747
+ repository history was reset in 0.7.0), but a copy you already obtained under MIT remains yours under those terms.
740
748
 
741
749
  Need something the license does not cover, such as offering it as part of a hosted service? Get in touch - commercial
742
750
  licensing may be available. This is a summary, not legal advice.
@@ -13,6 +13,12 @@
13
13
 
14
14
  [**Full documentation**](https://AnanthaRajuC.github.io/QueryAPIGate/)
15
15
 
16
+ <p align="center">
17
+ <a href="https://www.youtube.com/watch?v=aiVbAkA2LS8">
18
+ <img src="https://raw.githubusercontent.com/AnanthaRajuC/QueryAPIGate/main/documentation/screenshots/demo-thumbnail.png" alt="Watch the 60-second demo: saved SQL queries as REST endpoints, scoped API keys, and the Move preview showing which keys gain or lose access" width="640">
19
+ </a>
20
+ </p>
21
+
16
22
  **QueryAPIGate** is a self-hosted, single Flask service that runs SQL against your databases and returns the results as
17
23
  JSON, NDJSON, XML, YAML, CSV, TSV or Excel. Save a query once and it becomes a versioned endpoint with typed,
18
24
  injection-safe parameters and run history - without writing a controller, a repository layer, pagination, auth or
@@ -550,7 +556,7 @@ instead of it - a request has to pass both. See [Per-key rate limiting](document
550
556
  Every release is published to GitHub Container Registry for `linux/amd64` and `linux/arm64`:
551
557
 
552
558
  ~~~bash
553
- docker run -p 5000:5000 -v "$PWD/data:/data" -e QUERYAPIGATE_API_KEY=change-me ghcr.io/anantharajuc/queryapigate:latest
559
+ docker run -p 5000:5000 -v queryapigate-data:/data -e QUERYAPIGATE_API_KEY=change-me ghcr.io/anantharajuc/queryapigate:latest
554
560
  ~~~
555
561
 
556
562
  | Tag | Contents |
@@ -559,7 +565,10 @@ docker run -p 5000:5000 -v "$PWD/data:/data" -e QUERYAPIGATE_API_KEY=change-me g
559
565
  | `X.Y.Z-h2`, `latest-h2` | The same plus Java and the H2 driver - also the variant to use for a generic `jdbc` connection (mount your vendor's jar) |
560
566
 
561
567
  The container keeps `db_connections.json` and `saved_sql/` in `/data` (create a starter with
562
- `docker run --rm -v "$PWD/data:/data" ghcr.io/anantharajuc/queryapigate queryapigate init`). It runs as a non-root user under
568
+ `docker run --rm -v queryapigate-data:/data ghcr.io/anantharajuc/queryapigate queryapigate init`). The named volume above works
569
+ out of the box. To use a folder on the host instead (`-v "$PWD/data:/data"`), create it yourself first (`mkdir data`) and make sure
570
+ it is writable by uid 1000, the container's user - a folder that Docker creates for you is owned by root, which the container
571
+ cannot write to (or run with `--user "$(id -u):$(id -g)"`). It runs as a non-root user under
563
572
  gunicorn with one worker (the files are protected by an in-process lock) and a health check on `/health`. Behind a
564
573
  reverse proxy or load balancer, set `QUERYAPIGATE_TRUST_PROXY=1`. To build it yourself:
565
574
  `docker build -t queryapigate .` (add `--build-arg WITH_H2=true` for H2).
@@ -674,9 +683,8 @@ In plain terms - the [LICENSE](https://github.com/AnanthaRajuC/QueryAPIGate/blob
674
683
  - **You may not** make it available to others in a commercial product or service that substitutes for QueryAPIGate or
675
684
  offers the same or substantially similar functionality (for example, selling it, or hosting it as a paid service).
676
685
  - **Each version becomes MIT** on the second anniversary of the date it was released, and can then be used under MIT terms.
677
- - **Versions up to and including 0.6.1** were released under the MIT license and remain available under it (the
678
- releases on PyPI: [`queryapigate`](https://pypi.org/project/queryapigate/) 0.5.0-0.6.1 and
679
- [`sql2api`](https://pypi.org/project/sql2api/) 0.1.0-0.4.0).
686
+ - **Versions up to and including 0.6.1** were released under the MIT license. They are no longer distributed (and the
687
+ repository history was reset in 0.7.0), but a copy you already obtained under MIT remains yours under those terms.
680
688
 
681
689
  Need something the license does not cover, such as offering it as part of a hosted service? Get in touch - commercial
682
690
  licensing may be available. This is a summary, not legal advice.
@@ -1,5 +1,5 @@
1
1
  """QueryAPIGate - expose SQL databases as a REST API."""
2
- __version__ = '0.7.0'
2
+ __version__ = '0.7.1'
3
3
 
4
4
  from .app import create_app # noqa: E402 (app imports __version__)
5
5
 
@@ -76,6 +76,10 @@ def load_examples_at_startup():
76
76
  log.warning('QUERYAPIGATE_LOAD_EXAMPLES is set but the examples could not be loaded: %s',
77
77
  getattr(error, 'message', error))
78
78
  return
79
+ except Exception as error: # a convenience must never take the server down, whatever went wrong
80
+ log.warning('QUERYAPIGATE_LOAD_EXAMPLES is set but the examples could not be loaded (unexpected error: %s)',
81
+ error, exc_info=True)
82
+ return
79
83
  if added['connection'] or added['queries'] or added['roles']:
80
84
  store.record_audit('startup', 'load_examples', 'examples', added)
81
85
  log.info('Loaded the example APIs: %d queries, %d roles', len(added['queries']), len(added['roles']))
@@ -19,6 +19,7 @@ walkthrough in ``documentation/EXAMPLES.md`` shows how to create one from the ex
19
19
  """
20
20
  import os
21
21
  import random
22
+ import sqlite3
22
23
  from datetime import datetime, timedelta, timezone
23
24
  from typing import Any
24
25
 
@@ -78,7 +79,6 @@ CREATE INDEX rental_film_idx ON rental(film_id);
78
79
  def build_database(path, now=None, seed=42):
79
80
  """Create the example SQLite database at ``path`` (which must not exist). Deterministic for a given ``now`` and
80
81
  ``seed``. Dates are UTC, to match SQLite's own ``'now'`` that the example queries compare against."""
81
- import sqlite3
82
82
  rng = random.Random(seed)
83
83
  now = now or datetime.now(timezone.utc).replace(tzinfo=None)
84
84
  titles = [f'{a} {n}' for a in _ADJECTIVES for n in _NOUNS]
@@ -274,7 +274,11 @@ def load(now=None):
274
274
  db_path = config.home() / DB_FILE
275
275
  if _marked_connection() is None:
276
276
  if not db_path.exists():
277
- build_database(str(db_path), now)
277
+ try:
278
+ build_database(str(db_path), now)
279
+ except sqlite3.Error as error: # SQLite's own error type - not an OSError, so callers would miss it
280
+ raise ApiError(f'Could not create the example database in {config.home()} ({error}). Is the data '
281
+ 'folder writable by the user QueryAPIGate runs as?', 500) from None
278
282
  store.update_connections({CONNECTION: {'db': 'sqlite', 'database': DB_FILE, 'active': True,
279
283
  'example': True}})
280
284
  added['connection'] = True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queryapigate
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: Turn SQL into a REST API: run queries against MySQL, PostgreSQL, ClickHouse, SQLite or H2 over HTTP and get JSON, CSV, XML, YAML or XLSX back.
5
5
  Author-email: Anantha Raju C <arcswdev@gmail.com>
6
6
  License-Expression: FSL-1.1-MIT
@@ -73,6 +73,12 @@ Dynamic: license-file
73
73
 
74
74
  [**Full documentation**](https://AnanthaRajuC.github.io/QueryAPIGate/)
75
75
 
76
+ <p align="center">
77
+ <a href="https://www.youtube.com/watch?v=aiVbAkA2LS8">
78
+ <img src="https://raw.githubusercontent.com/AnanthaRajuC/QueryAPIGate/main/documentation/screenshots/demo-thumbnail.png" alt="Watch the 60-second demo: saved SQL queries as REST endpoints, scoped API keys, and the Move preview showing which keys gain or lose access" width="640">
79
+ </a>
80
+ </p>
81
+
76
82
  **QueryAPIGate** is a self-hosted, single Flask service that runs SQL against your databases and returns the results as
77
83
  JSON, NDJSON, XML, YAML, CSV, TSV or Excel. Save a query once and it becomes a versioned endpoint with typed,
78
84
  injection-safe parameters and run history - without writing a controller, a repository layer, pagination, auth or
@@ -610,7 +616,7 @@ instead of it - a request has to pass both. See [Per-key rate limiting](document
610
616
  Every release is published to GitHub Container Registry for `linux/amd64` and `linux/arm64`:
611
617
 
612
618
  ~~~bash
613
- docker run -p 5000:5000 -v "$PWD/data:/data" -e QUERYAPIGATE_API_KEY=change-me ghcr.io/anantharajuc/queryapigate:latest
619
+ docker run -p 5000:5000 -v queryapigate-data:/data -e QUERYAPIGATE_API_KEY=change-me ghcr.io/anantharajuc/queryapigate:latest
614
620
  ~~~
615
621
 
616
622
  | Tag | Contents |
@@ -619,7 +625,10 @@ docker run -p 5000:5000 -v "$PWD/data:/data" -e QUERYAPIGATE_API_KEY=change-me g
619
625
  | `X.Y.Z-h2`, `latest-h2` | The same plus Java and the H2 driver - also the variant to use for a generic `jdbc` connection (mount your vendor's jar) |
620
626
 
621
627
  The container keeps `db_connections.json` and `saved_sql/` in `/data` (create a starter with
622
- `docker run --rm -v "$PWD/data:/data" ghcr.io/anantharajuc/queryapigate queryapigate init`). It runs as a non-root user under
628
+ `docker run --rm -v queryapigate-data:/data ghcr.io/anantharajuc/queryapigate queryapigate init`). The named volume above works
629
+ out of the box. To use a folder on the host instead (`-v "$PWD/data:/data"`), create it yourself first (`mkdir data`) and make sure
630
+ it is writable by uid 1000, the container's user - a folder that Docker creates for you is owned by root, which the container
631
+ cannot write to (or run with `--user "$(id -u):$(id -g)"`). It runs as a non-root user under
623
632
  gunicorn with one worker (the files are protected by an in-process lock) and a health check on `/health`. Behind a
624
633
  reverse proxy or load balancer, set `QUERYAPIGATE_TRUST_PROXY=1`. To build it yourself:
625
634
  `docker build -t queryapigate .` (add `--build-arg WITH_H2=true` for H2).
@@ -734,9 +743,8 @@ In plain terms - the [LICENSE](https://github.com/AnanthaRajuC/QueryAPIGate/blob
734
743
  - **You may not** make it available to others in a commercial product or service that substitutes for QueryAPIGate or
735
744
  offers the same or substantially similar functionality (for example, selling it, or hosting it as a paid service).
736
745
  - **Each version becomes MIT** on the second anniversary of the date it was released, and can then be used under MIT terms.
737
- - **Versions up to and including 0.6.1** were released under the MIT license and remain available under it (the
738
- releases on PyPI: [`queryapigate`](https://pypi.org/project/queryapigate/) 0.5.0-0.6.1 and
739
- [`sql2api`](https://pypi.org/project/sql2api/) 0.1.0-0.4.0).
746
+ - **Versions up to and including 0.6.1** were released under the MIT license. They are no longer distributed (and the
747
+ repository history was reset in 0.7.0), but a copy you already obtained under MIT remains yours under those terms.
740
748
 
741
749
  Need something the license does not cover, such as offering it as part of a hosted service? Get in touch - commercial
742
750
  licensing may be available. This is a summary, not legal advice.
@@ -39,6 +39,14 @@ class ExamplesTestCase(unittest.TestCase):
39
39
  store.save_version(name, {'sql_query': 'SELECT 1', 'author': 'me', 'description': 'mine', 'tags': [],
40
40
  'query_parameters': {}}, collection)
41
41
 
42
+ def make_home_unwritable(self):
43
+ """A real read-only data folder - what a Docker bind mount created by root looks like to the container's
44
+ non-root user. (Mocking an OSError would not do: SQLite raises its own error type here.)"""
45
+ if os.geteuid() == 0:
46
+ self.skipTest('root ignores directory permissions')
47
+ os.chmod(self.home, 0o555)
48
+ self.addCleanup(os.chmod, self.home, 0o755)
49
+
42
50
  def run_cli(self, *argv):
43
51
  out, err = io.StringIO(), io.StringIO()
44
52
  with redirect_stdout(out), redirect_stderr(err):
@@ -47,6 +55,13 @@ class ExamplesTestCase(unittest.TestCase):
47
55
 
48
56
 
49
57
  class DatabaseTests(ExamplesTestCase):
58
+ def test_an_unwritable_folder_is_an_apierror_with_a_hint(self):
59
+ self.make_home_unwritable()
60
+ with self.assertRaises(ApiError) as caught:
61
+ examples.load()
62
+ self.assertEqual(caught.exception.status, 500)
63
+ self.assertIn('writable', caught.exception.message)
64
+
50
65
  def build(self, **kwargs):
51
66
  path = os.path.join(self.home, 'x.db')
52
67
  examples.build_database(path, FIXED_NOW, **kwargs)
@@ -450,6 +465,24 @@ class StartupTests(ExamplesTestCase):
450
465
  self.assertEqual(app.test_client().get('/health').status_code, 200)
451
466
  self.assertEqual(self.saved_files(), ['example_top_films.json'])
452
467
 
468
+ def test_a_really_unwritable_home_is_a_warning_not_a_crash(self):
469
+ # This crashed the container: examples.load() let SQLite's own OperationalError escape, which the startup hook
470
+ # (catching only ApiError/OSError) did not handle, so the worker died and gunicorn kept restarting it.
471
+ self.make_home_unwritable()
472
+ os.environ['QUERYAPIGATE_LOAD_EXAMPLES'] = 'yes'
473
+ with self.assertLogs('queryapigate', level='WARNING') as captured:
474
+ app = create_app()
475
+ self.assertTrue(any('could not be loaded' in line for line in captured.output), captured.output)
476
+ self.assertEqual(app.test_client().get('/health').status_code, 200)
477
+
478
+ def test_an_unexpected_failure_while_loading_is_still_only_a_warning(self):
479
+ os.environ['QUERYAPIGATE_LOAD_EXAMPLES'] = 'yes'
480
+ with mock.patch.object(examples, 'load', side_effect=RuntimeError('something new')):
481
+ with self.assertLogs('queryapigate', level='WARNING') as captured:
482
+ app = create_app()
483
+ self.assertTrue(any('something new' in line for line in captured.output), captured.output)
484
+ self.assertEqual(app.test_client().get('/health').status_code, 200)
485
+
453
486
  def test_a_read_only_home_is_a_warning_not_a_startup_failure(self):
454
487
  os.environ['QUERYAPIGATE_LOAD_EXAMPLES'] = 'yes'
455
488
  with mock.patch.object(examples, 'load', side_effect=OSError('read-only file system')):
@@ -489,6 +522,13 @@ class CliTests(ExamplesTestCase):
489
522
  _, out, _ = self.run_cli('examples', 'unload')
490
523
  self.assertIn('partner-key', out)
491
524
 
525
+ def test_an_unwritable_home_is_a_clean_message_not_a_traceback(self):
526
+ self.make_home_unwritable()
527
+ code, _, err = self.run_cli('examples', 'load')
528
+ self.assertEqual(code, 1)
529
+ self.assertIn('queryapigate examples load:', err)
530
+ self.assertNotIn('Traceback', err)
531
+
492
532
  def test_bare_command_shows_help_and_a_leftover_old_setting_still_stops_it(self):
493
533
  code, out, _ = self.run_cli('examples')
494
534
  self.assertEqual((code, 'load' in out), (2, True))
File without changes
File without changes