diff --git a/analytics_data_api/management/commands/check_insights_snowflake.py b/analytics_data_api/management/commands/check_insights_snowflake.py new file mode 100644 index 00000000..48935ab8 --- /dev/null +++ b/analytics_data_api/management/commands/check_insights_snowflake.py @@ -0,0 +1,49 @@ +""" +Validate the Insights Snowflake connection from the deployed Analytics API service. + +Temporary Phase 1 smoke-test command. Remove it after Snowflake connectivity +validation is complete and real endpoint migration work owns the connection path. +""" + +from django.core.management.base import BaseCommand, CommandError + +from analytics_data_api.snowflake_client import ( + DEFAULT_VALIDATION_TABLE, + SnowflakeConfigurationError, + get_insights_snowflake_config, + get_table_row_count, +) + + +class Command(BaseCommand): + """Run a manual read-only Snowflake connectivity check.""" + + help = 'Validate Insights Snowflake connectivity with a read-only query.' + + def add_arguments(self, parser): + parser.add_argument( + '--table', + default=DEFAULT_VALIDATION_TABLE, + help='Snowflake table to count for validation. Defaults to COURSE_ACTIVITY_WEEKLY.', + ) + + def handle(self, *args, **options): + table = options['table'] + + try: + config = get_insights_snowflake_config() + row_count = get_table_row_count(table) + except SnowflakeConfigurationError as exc: + raise CommandError(str(exc)) + except Exception as exc: # pylint: disable=broad-except + raise CommandError('Insights Snowflake connectivity check failed: {}'.format(exc)) + + self.stdout.write('Insights Snowflake connectivity check succeeded.') + self.stdout.write('Account: {}'.format(config['ACCOUNT'])) + self.stdout.write('User: {}'.format(config['USER'])) + self.stdout.write('Role: {}'.format(config['ROLE'])) + self.stdout.write('Warehouse: {}'.format(config['WAREHOUSE'])) + self.stdout.write('Database: {}'.format(config['DATABASE'])) + self.stdout.write('Schema: {}'.format(config['SCHEMA'])) + self.stdout.write('Table: {}'.format(table)) + self.stdout.write('Row count: {}'.format(row_count)) diff --git a/analytics_data_api/management/commands/tests/test_check_insights_snowflake.py b/analytics_data_api/management/commands/tests/test_check_insights_snowflake.py new file mode 100644 index 00000000..26a88bdb --- /dev/null +++ b/analytics_data_api/management/commands/tests/test_check_insights_snowflake.py @@ -0,0 +1,66 @@ +"""Tests for the temporary Insights Snowflake smoke-test command.""" + +from io import StringIO +from unittest.mock import patch + +from django.core.management import CommandError, call_command +from django.test import SimpleTestCase + +from analytics_data_api.snowflake_client import SnowflakeConfigurationError + +VALID_CONFIG = { + 'ACCOUNT': 'edx.us-east-1', + 'USER': 'INSIGHTS_API_SERVICE_USER', + 'ROLE': 'INSIGHTS_API_SERVICE_ROLE', + 'WAREHOUSE': 'INSIGHTS_API_SERVICE', + 'DATABASE': 'PROD', + 'SCHEMA': 'INSIGHTS', + 'PRIVATE_KEY': 'secret-private-key', +} + + +class CheckInsightsSnowflakeCommandTests(SimpleTestCase): + """ + Cover the Phase 1 smoke-test command. + + Remove these tests with the command after Snowflake connectivity validation + is complete and endpoint migration owns the connection path. + """ + + @patch('analytics_data_api.management.commands.check_insights_snowflake.get_table_row_count') + @patch('analytics_data_api.management.commands.check_insights_snowflake.get_insights_snowflake_config') + def test_command_prints_non_secret_connection_context(self, mock_get_config, mock_get_table_row_count): + mock_get_config.return_value = VALID_CONFIG + mock_get_table_row_count.return_value = 42 + stdout = StringIO() + + call_command('check_insights_snowflake', '--table', 'COURSE_ACTIVITY_WEEKLY', stdout=stdout) + + output = stdout.getvalue() + self.assertIn('Insights Snowflake connectivity check succeeded.', output) + self.assertIn('Account: edx.us-east-1', output) + self.assertIn('User: INSIGHTS_API_SERVICE_USER', output) + self.assertIn('Role: INSIGHTS_API_SERVICE_ROLE', output) + self.assertIn('Warehouse: INSIGHTS_API_SERVICE', output) + self.assertIn('Database: PROD', output) + self.assertIn('Schema: INSIGHTS', output) + self.assertIn('Table: COURSE_ACTIVITY_WEEKLY', output) + self.assertIn('Row count: 42', output) + self.assertNotIn('secret-private-key', output) + mock_get_table_row_count.assert_called_once_with('COURSE_ACTIVITY_WEEKLY') + + @patch('analytics_data_api.management.commands.check_insights_snowflake.get_insights_snowflake_config') + def test_command_raises_config_errors(self, mock_get_config): + mock_get_config.side_effect = SnowflakeConfigurationError('missing config') + + with self.assertRaisesRegex(CommandError, 'missing config'): + call_command('check_insights_snowflake') + + @patch('analytics_data_api.management.commands.check_insights_snowflake.get_table_row_count') + @patch('analytics_data_api.management.commands.check_insights_snowflake.get_insights_snowflake_config') + def test_command_wraps_unexpected_errors(self, mock_get_config, mock_get_table_row_count): + mock_get_config.return_value = VALID_CONFIG + mock_get_table_row_count.side_effect = RuntimeError('snowflake unavailable') + + with self.assertRaisesRegex(CommandError, 'Insights Snowflake connectivity check failed'): + call_command('check_insights_snowflake') diff --git a/analytics_data_api/snowflake_client.py b/analytics_data_api/snowflake_client.py new file mode 100644 index 00000000..194f0647 --- /dev/null +++ b/analytics_data_api/snowflake_client.py @@ -0,0 +1,125 @@ +""" +Helpers for the Insights Snowflake connection. + +This module is intentionally not used by existing API views. Phase 1 only uses +it from a manual management command to validate deployed Snowflake connectivity. +""" + +import re +from importlib import import_module + +from cryptography.hazmat.primitives import serialization +from django.conf import settings + +QUERY_TAG = 'edx_analytics_api_insights_snowflake_phase1' +DEFAULT_VALIDATION_TABLE = 'COURSE_ACTIVITY_WEEKLY' +REQUIRED_CONFIG_KEYS = ( + 'ACCOUNT', + 'USER', + 'ROLE', + 'WAREHOUSE', + 'DATABASE', + 'SCHEMA', + 'PRIVATE_KEY', +) +IDENTIFIER_PATTERN = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') + + +class SnowflakeConfigurationError(Exception): + """Raised when Insights Snowflake configuration is missing or unsafe.""" + + +def get_insights_snowflake_config(): + """Return the configured Insights Snowflake settings.""" + config = getattr(settings, 'INSIGHTS_SNOWFLAKE', None) + if not isinstance(config, dict): + raise SnowflakeConfigurationError('INSIGHTS_SNOWFLAKE must be configured as a dictionary.') + + missing_keys = [key for key in REQUIRED_CONFIG_KEYS if not config.get(key)] + if missing_keys: + raise SnowflakeConfigurationError( + 'INSIGHTS_SNOWFLAKE is missing required settings: {}.'.format(', '.join(missing_keys)) + ) + + return config + + +def get_private_key_der(config): + """ + Convert the configured PEM private key string into DER bytes. + + edx-internal stores the Snowflake private key with literal "\\n" sequences. + The connector expects a loaded private key serialized as DER/PKCS8 bytes. + """ + private_key_value = config['PRIVATE_KEY'] + if not isinstance(private_key_value, str): + raise SnowflakeConfigurationError('INSIGHTS_SNOWFLAKE PRIVATE_KEY must be a string.') + + normalized_private_key = private_key_value.replace('\\n', '\n').encode('utf-8') + passphrase = config.get('PRIVATE_KEY_PASSPHRASE') + passphrase_bytes = passphrase.encode('utf-8') if passphrase else None + + private_key = serialization.load_pem_private_key( + normalized_private_key, + password=passphrase_bytes, + ) + + return private_key.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + +def connect_to_insights_snowflake(): + """Open a Snowflake connection using the Insights Snowflake settings.""" + config = get_insights_snowflake_config() + snowflake_connector = import_module('snowflake.connector') + + return snowflake_connector.connect( + account=config['ACCOUNT'], + user=config['USER'], + role=config['ROLE'], + warehouse=config['WAREHOUSE'], + database=config['DATABASE'], + schema=config['SCHEMA'], + private_key=get_private_key_der(config), + session_parameters={ + 'QUERY_TAG': QUERY_TAG, + }, + ) + + +def validate_snowflake_identifier(identifier, label): + """Return a safe Snowflake identifier or raise a sanitized error.""" + if not isinstance(identifier, str) or not IDENTIFIER_PATTERN.match(identifier): + raise SnowflakeConfigurationError('Unsafe Snowflake {} identifier.'.format(label)) + return identifier.upper() + + +def run_read_only_query(sql): + """Run a read-only Snowflake query and return all rows.""" + if not sql.lstrip().upper().startswith('SELECT '): + raise SnowflakeConfigurationError('Only SELECT queries are allowed for Insights Snowflake validation.') + + connection = connect_to_insights_snowflake() + cursor = None + try: + cursor = connection.cursor() + cursor.execute(sql) + return cursor.fetchall() + finally: + if cursor is not None: + cursor.close() + connection.close() + + +def get_table_row_count(table_name=DEFAULT_VALIDATION_TABLE): + """Return the row count for a validated Snowflake table name.""" + config = get_insights_snowflake_config() + database = validate_snowflake_identifier(config['DATABASE'], 'database') + schema = validate_snowflake_identifier(config['SCHEMA'], 'schema') + table = validate_snowflake_identifier(table_name, 'table') + + rows = run_read_only_query('SELECT COUNT(*) AS row_count FROM {}.{}.{}'.format(database, schema, table)) + return rows[0][0] if rows else 0 diff --git a/analytics_data_api/tests/test_snowflake_client.py b/analytics_data_api/tests/test_snowflake_client.py new file mode 100644 index 00000000..4c1c4bbb --- /dev/null +++ b/analytics_data_api/tests/test_snowflake_client.py @@ -0,0 +1,126 @@ +"""Tests for the temporary Insights Snowflake connectivity helper.""" + +from unittest.mock import Mock, patch + +from django.test import SimpleTestCase, override_settings + +from analytics_data_api.snowflake_client import ( + QUERY_TAG, + SnowflakeConfigurationError, + connect_to_insights_snowflake, + get_insights_snowflake_config, + get_private_key_der, + get_table_row_count, + run_read_only_query, + validate_snowflake_identifier, +) + +VALID_CONFIG = { + 'ACCOUNT': 'edx.us-east-1', + 'USER': 'INSIGHTS_API_SERVICE_USER', + 'ROLE': 'INSIGHTS_API_SERVICE_ROLE', + 'WAREHOUSE': 'INSIGHTS_API_SERVICE', + 'DATABASE': 'PROD', + 'SCHEMA': 'INSIGHTS', + 'PRIVATE_KEY': '-----BEGIN PRIVATE KEY-----\\nkey\\n-----END PRIVATE KEY-----', + 'PRIVATE_KEY_PASSPHRASE': None, +} + + +class SnowflakeClientTests(SimpleTestCase): + """Cover the connection helper without making real Snowflake calls.""" + + @override_settings(INSIGHTS_SNOWFLAKE=VALID_CONFIG) + def test_get_insights_snowflake_config(self): + self.assertEqual(get_insights_snowflake_config(), VALID_CONFIG) + + @override_settings(INSIGHTS_SNOWFLAKE=None) + def test_get_insights_snowflake_config_requires_dictionary(self): + with self.assertRaisesRegex(SnowflakeConfigurationError, 'must be configured as a dictionary'): + get_insights_snowflake_config() + + @override_settings(INSIGHTS_SNOWFLAKE={}) + def test_get_insights_snowflake_config_requires_values(self): + with self.assertRaisesRegex(SnowflakeConfigurationError, 'missing required settings'): + get_insights_snowflake_config() + + def test_get_private_key_der_requires_string_key(self): + config = dict(VALID_CONFIG, PRIVATE_KEY=None) + + with self.assertRaisesRegex(SnowflakeConfigurationError, 'PRIVATE_KEY must be a string'): + get_private_key_der(config) + + @patch('analytics_data_api.snowflake_client.serialization.load_pem_private_key') + def test_get_private_key_der_converts_key_and_passphrase(self, mock_load_pem_private_key): + private_key = Mock() + private_key.private_bytes.return_value = b'der-key' + mock_load_pem_private_key.return_value = private_key + config = dict(VALID_CONFIG, PRIVATE_KEY='line1\\nline2', PRIVATE_KEY_PASSPHRASE='passphrase') + + self.assertEqual(get_private_key_der(config), b'der-key') + + mock_load_pem_private_key.assert_called_once_with(b'line1\nline2', password=b'passphrase') + + @override_settings(INSIGHTS_SNOWFLAKE=VALID_CONFIG) + @patch('analytics_data_api.snowflake_client.get_private_key_der') + @patch('analytics_data_api.snowflake_client.import_module') + def test_connect_to_insights_snowflake_uses_config(self, mock_import_module, mock_get_private_key_der): + connector = Mock() + mock_import_module.return_value = connector + mock_get_private_key_der.return_value = b'der-key' + + connect_to_insights_snowflake() + + connector.connect.assert_called_once_with( + account='edx.us-east-1', + user='INSIGHTS_API_SERVICE_USER', + role='INSIGHTS_API_SERVICE_ROLE', + warehouse='INSIGHTS_API_SERVICE', + database='PROD', + schema='INSIGHTS', + private_key=b'der-key', + session_parameters={'QUERY_TAG': QUERY_TAG}, + ) + + def test_validate_snowflake_identifier(self): + self.assertEqual(validate_snowflake_identifier('course_activity_weekly', 'table'), 'COURSE_ACTIVITY_WEEKLY') + + def test_validate_snowflake_identifier_rejects_unsafe_values(self): + with self.assertRaisesRegex(SnowflakeConfigurationError, 'Unsafe Snowflake table identifier'): + validate_snowflake_identifier('course_activity_weekly;drop table users', 'table') + + def test_run_read_only_query_rejects_non_select_sql(self): + with self.assertRaisesRegex(SnowflakeConfigurationError, 'Only SELECT queries are allowed'): + run_read_only_query('DELETE FROM PROD.INSIGHTS.COURSE_ACTIVITY_WEEKLY') + + @patch('analytics_data_api.snowflake_client.connect_to_insights_snowflake') + def test_run_read_only_query_closes_resources(self, mock_connect_to_insights_snowflake): + cursor = Mock() + cursor.fetchall.return_value = [(3,)] + connection = Mock() + connection.cursor.return_value = cursor + mock_connect_to_insights_snowflake.return_value = connection + + self.assertEqual(run_read_only_query('SELECT 1'), [(3,)]) + + cursor.execute.assert_called_once_with('SELECT 1') + cursor.close.assert_called_once_with() + connection.close.assert_called_once_with() + + @override_settings(INSIGHTS_SNOWFLAKE=VALID_CONFIG) + @patch('analytics_data_api.snowflake_client.run_read_only_query') + def test_get_table_row_count(self, mock_run_read_only_query): + mock_run_read_only_query.return_value = [(12,)] + + self.assertEqual(get_table_row_count('course_activity_weekly'), 12) + + mock_run_read_only_query.assert_called_once_with( + 'SELECT COUNT(*) AS row_count FROM PROD.INSIGHTS.COURSE_ACTIVITY_WEEKLY' + ) + + @override_settings(INSIGHTS_SNOWFLAKE=VALID_CONFIG) + @patch('analytics_data_api.snowflake_client.run_read_only_query') + def test_get_table_row_count_handles_empty_result(self, mock_run_read_only_query): + mock_run_read_only_query.return_value = [] + + self.assertEqual(get_table_row_count(), 0) diff --git a/analyticsdataserver/settings/base.py b/analyticsdataserver/settings/base.py index cf426db1..630f5f98 100644 --- a/analyticsdataserver/settings/base.py +++ b/analyticsdataserver/settings/base.py @@ -90,6 +90,19 @@ } ########## END DATABASE CONFIGURATION +########## INSIGHTS SNOWFLAKE CONFIGURATION +INSIGHTS_SNOWFLAKE = { + 'ACCOUNT': None, + 'USER': None, + 'ROLE': None, + 'WAREHOUSE': None, + 'DATABASE': 'PROD', + 'SCHEMA': 'INSIGHTS', + 'PRIVATE_KEY': None, + 'PRIVATE_KEY_PASSPHRASE': None, +} +########## END INSIGHTS SNOWFLAKE CONFIGURATION + ########## GENERAL CONFIGURATION # See: https://docs.djangoproject.com/en/dev/ref/settings/#time-zone TIME_ZONE = 'UTC' diff --git a/requirements/base.in b/requirements/base.in index eb4b8ed6..af3d963c 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -9,10 +9,12 @@ Django # BSD License django-countries # MIT python-memcached # Python Software Foundation License v2 pymemcache +cryptography # Apache-2.0 OR BSD +snowflake-connector-python # Apache 2.0 djangorestframework # BSD djangorestframework-csv # BSD django-storages # BSD -html5lib # MIT +html5lib # MIT ordered-set # MIT tqdm # MIT urllib3 # MIT diff --git a/requirements/base.txt b/requirements/base.txt index aa620ee3..f6f1caec 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -2,35 +2,47 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# make upgrade +# pip-compile --output-file=requirements/base.txt requirements/base.in # asgiref==3.11.1 # via # django # django-cors-headers # django-countries +asn1crypto==1.5.1 + # via snowflake-connector-python boto==2.49.0 # via -r requirements/base.in boto3==1.42.90 - # via -r requirements/base.in + # via + # -r requirements/base.in + # snowflake-connector-python botocore==1.42.90 # via # boto3 # s3transfer + # snowflake-connector-python certifi==2026.2.25 - # via requests + # via + # requests + # snowflake-connector-python cffi==2.0.0 # via # cryptography # pynacl charset-normalizer==3.4.7 - # via requests + # via + # requests + # snowflake-connector-python click==8.3.2 # via edx-django-utils cryptography==46.0.7 # via + # -r requirements/base.in # django-fernet-fields-v2 # pyjwt + # pyopenssl + # snowflake-connector-python django==4.2.30 # via # -c requirements/constraints.txt @@ -130,10 +142,14 @@ factory-boy==3.3.3 # via edx-enterprise-data faker==40.13.0 # via factory-boy +filelock==3.32.0 + # via snowflake-connector-python html5lib==1.1 # via -r requirements/base.in idna==3.11 - # via requests + # via + # requests + # snowflake-connector-python inflection==0.5.1 # via drf-yasg jmespath==1.1.0 @@ -147,7 +163,11 @@ mysql-connector-python==9.5.0 ordered-set==4.1.0 # via -r requirements/base.in packaging==26.1 - # via drf-yasg + # via + # drf-yasg + # snowflake-connector-python +platformdirs==4.11.0 + # via snowflake-connector-python psutil==7.2.2 # via edx-django-utils pycparser==3.0 @@ -157,18 +177,23 @@ pyjwt[crypto]==2.12.1 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # snowflake-connector-python pymemcache==4.0.0 # via -r requirements/base.in pymongo==4.16.0 # via edx-opaque-keys pynacl==1.6.2 # via edx-django-utils +pyopenssl==26.2.0 + # via snowflake-connector-python python-dateutil==2.9.0.post0 # via botocore python-memcached==1.62 # via -r requirements/base.in pytz==2026.1.post1 - # via drf-yasg + # via + # drf-yasg + # snowflake-connector-python pyyaml==6.0.3 # via # drf-yasg @@ -178,6 +203,7 @@ requests==2.33.1 # edx-drf-extensions # edx-enterprise-data # edx-rest-api-client + # snowflake-connector-python rules==3.5 # via edx-enterprise-data s3transfer==0.16.0 @@ -191,18 +217,28 @@ six==1.17.0 # edx-rbac # html5lib # python-dateutil +snowflake-connector-python==4.7.1 + # via + # -r requirements/base.in + # edx-enterprise-data +sortedcontainers==2.4.0 + # via snowflake-connector-python sqlparse==0.5.5 # via django stevedore==5.7.0 # via # edx-django-utils # edx-opaque-keys +tomlkit==0.15.1 + # via snowflake-connector-python tqdm==4.67.3 # via -r requirements/base.in typing-extensions==4.15.0 # via # django-countries # edx-opaque-keys + # pyopenssl + # snowflake-connector-python uritemplate==4.2.0 # via drf-yasg urllib3==1.26.20 diff --git a/requirements/dev.txt b/requirements/dev.txt index b0cf19be..d150ffc1 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -2,35 +2,47 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# make upgrade +# pip-compile --output-file=requirements/dev.txt requirements/dev.in # asgiref==3.11.1 # via # django # django-cors-headers # django-countries +asn1crypto==1.5.1 + # via snowflake-connector-python boto==2.49.0 # via -r requirements/base.in boto3==1.42.90 - # via -r requirements/base.in + # via + # -r requirements/base.in + # snowflake-connector-python botocore==1.42.90 # via # boto3 # s3transfer + # snowflake-connector-python certifi==2026.2.25 - # via requests + # via + # requests + # snowflake-connector-python cffi==2.0.0 # via # cryptography # pynacl charset-normalizer==3.4.7 - # via requests + # via + # requests + # snowflake-connector-python click==8.3.2 # via edx-django-utils cryptography==46.0.7 # via + # -r requirements/base.in # django-fernet-fields-v2 # pyjwt + # pyopenssl + # snowflake-connector-python django==4.2.30 # via # -c requirements/constraints.txt @@ -112,7 +124,6 @@ edx-drf-extensions==10.6.0 # -r requirements/base.in # edx-enterprise-data # edx-rbac - edx-enterprise-data==10.22.10 # via -r requirements/base.in edx-opaque-keys==4.0.0 @@ -131,10 +142,14 @@ factory-boy==3.3.3 # via edx-enterprise-data faker==40.13.0 # via factory-boy +filelock==3.32.0 + # via snowflake-connector-python html5lib==1.1 # via -r requirements/base.in idna==3.11 - # via requests + # via + # requests + # snowflake-connector-python inflection==0.5.1 # via drf-yasg jmespath==1.1.0 @@ -150,7 +165,11 @@ mysqlclient==2.2.8 ordered-set==4.1.0 # via -r requirements/base.in packaging==26.1 - # via drf-yasg + # via + # drf-yasg + # snowflake-connector-python +platformdirs==4.11.0 + # via snowflake-connector-python psutil==7.2.2 # via edx-django-utils pycparser==3.0 @@ -160,18 +179,23 @@ pyjwt[crypto]==2.12.1 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # snowflake-connector-python pymemcache==4.0.0 # via -r requirements/base.in pymongo==4.16.0 # via edx-opaque-keys pynacl==1.6.2 # via edx-django-utils +pyopenssl==26.2.0 + # via snowflake-connector-python python-dateutil==2.9.0.post0 # via botocore python-memcached==1.62 # via -r requirements/base.in pytz==2026.1.post1 - # via drf-yasg + # via + # drf-yasg + # snowflake-connector-python pyyaml==6.0.3 # via # drf-yasg @@ -181,6 +205,7 @@ requests==2.33.1 # edx-drf-extensions # edx-enterprise-data # edx-rest-api-client + # snowflake-connector-python rules==3.5 # via edx-enterprise-data s3transfer==0.16.0 @@ -194,18 +219,28 @@ six==1.17.0 # edx-rbac # html5lib # python-dateutil +snowflake-connector-python==4.7.1 + # via + # -r requirements/base.in + # edx-enterprise-data +sortedcontainers==2.4.0 + # via snowflake-connector-python sqlparse==0.5.5 # via django stevedore==5.7.0 # via # edx-django-utils # edx-opaque-keys +tomlkit==0.15.1 + # via snowflake-connector-python tqdm==4.67.3 # via -r requirements/base.in typing-extensions==4.15.0 # via # django-countries # edx-opaque-keys + # pyopenssl + # snowflake-connector-python uritemplate==4.2.0 # via drf-yasg urllib3==1.26.20 diff --git a/requirements/doc.txt b/requirements/doc.txt index 0186eb5a..d1c55148 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# make upgrade +# pip-compile --output-file=requirements/doc.txt requirements/doc.in # accessible-pygments==0.0.5 # via pydata-sphinx-theme @@ -13,6 +13,8 @@ asgiref==3.11.1 # django # django-cors-headers # django-countries +asn1crypto==1.5.1 + # via snowflake-connector-python babel==2.18.0 # via # pydata-sphinx-theme @@ -22,25 +24,35 @@ beautifulsoup4==4.14.3 boto==2.49.0 # via -r requirements/base.in boto3==1.42.90 - # via -r requirements/base.in + # via + # -r requirements/base.in + # snowflake-connector-python botocore==1.42.90 # via # boto3 # s3transfer + # snowflake-connector-python certifi==2026.2.25 - # via requests + # via + # requests + # snowflake-connector-python cffi==2.0.0 # via # cryptography # pynacl charset-normalizer==3.4.7 - # via requests + # via + # requests + # snowflake-connector-python click==8.3.2 # via edx-django-utils cryptography==46.0.7 # via + # -r requirements/base.in # django-fernet-fields-v2 # pyjwt + # pyopenssl + # snowflake-connector-python django==4.2.30 # via # -c requirements/constraints.txt @@ -144,10 +156,14 @@ factory-boy==3.3.3 # via edx-enterprise-data faker==40.13.0 # via factory-boy +filelock==3.32.0 + # via snowflake-connector-python html5lib==1.1 # via -r requirements/base.in idna==3.11 - # via requests + # via + # requests + # snowflake-connector-python imagesize==2.0.0 # via sphinx inflection==0.5.1 @@ -169,11 +185,14 @@ ordered-set==4.1.0 packaging==26.1 # via # drf-yasg + # snowflake-connector-python # sphinx path==16.14.0 # via # -c requirements/constraints.txt # -r requirements/doc.in +platformdirs==4.11.0 + # via snowflake-connector-python psutil==7.2.2 # via edx-django-utils pycparser==3.0 @@ -190,18 +209,23 @@ pyjwt[crypto]==2.12.1 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # snowflake-connector-python pymemcache==4.0.0 # via -r requirements/base.in pymongo==4.16.0 # via edx-opaque-keys pynacl==1.6.2 # via edx-django-utils +pyopenssl==26.2.0 + # via snowflake-connector-python python-dateutil==2.9.0.post0 # via botocore python-memcached==1.62 # via -r requirements/base.in pytz==2026.1.post1 - # via drf-yasg + # via + # drf-yasg + # snowflake-connector-python pyyaml==6.0.3 # via # drf-yasg @@ -211,6 +235,7 @@ requests==2.33.1 # edx-drf-extensions # edx-enterprise-data # edx-rest-api-client + # snowflake-connector-python # sphinx roman-numerals==4.1.0 # via sphinx @@ -229,6 +254,12 @@ six==1.17.0 # python-dateutil snowballstemmer==3.0.1 # via sphinx +snowflake-connector-python==4.7.1 + # via + # -r requirements/base.in + # edx-enterprise-data +sortedcontainers==2.4.0 + # via snowflake-connector-python soupsieve==2.8.3 # via beautifulsoup4 sphinx==9.0.4 @@ -256,6 +287,8 @@ stevedore==5.7.0 # via # edx-django-utils # edx-opaque-keys +tomlkit==0.15.1 + # via snowflake-connector-python tqdm==4.67.3 # via -r requirements/base.in typing-extensions==4.15.0 @@ -264,6 +297,8 @@ typing-extensions==4.15.0 # django-countries # edx-opaque-keys # pydata-sphinx-theme + # pyopenssl + # snowflake-connector-python uritemplate==4.2.0 # via drf-yasg urllib3==1.26.20 diff --git a/requirements/production.txt b/requirements/production.txt index 3df43b86..e144c090 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -2,35 +2,47 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# make upgrade +# pip-compile --output-file=requirements/production.txt requirements/production.in # asgiref==3.11.1 # via # django # django-cors-headers # django-countries +asn1crypto==1.5.1 + # via snowflake-connector-python boto==2.49.0 # via -r requirements/base.in boto3==1.42.90 - # via -r requirements/base.in + # via + # -r requirements/base.in + # snowflake-connector-python botocore==1.42.90 # via # boto3 # s3transfer + # snowflake-connector-python certifi==2026.2.25 - # via requests + # via + # requests + # snowflake-connector-python cffi==2.0.0 # via # cryptography # pynacl charset-normalizer==3.4.7 - # via requests + # via + # requests + # snowflake-connector-python click==8.3.2 # via edx-django-utils cryptography==46.0.7 # via + # -r requirements/base.in # django-fernet-fields-v2 # pyjwt + # pyopenssl + # snowflake-connector-python django==4.2.30 # via # -c requirements/constraints.txt @@ -130,6 +142,8 @@ factory-boy==3.3.3 # via edx-enterprise-data faker==40.13.0 # via factory-boy +filelock==3.32.0 + # via snowflake-connector-python gevent==26.4.0 # via -r requirements/production.in greenlet==3.4.0 @@ -139,7 +153,9 @@ gunicorn==25.3.0 html5lib==1.1 # via -r requirements/base.in idna==3.11 - # via requests + # via + # requests + # snowflake-connector-python inflection==0.5.1 # via drf-yasg jmespath==1.1.0 @@ -160,8 +176,11 @@ packaging==26.1 # via # drf-yasg # gunicorn + # snowflake-connector-python path-py==8.2.1 # via -r requirements/production.in +platformdirs==4.11.0 + # via snowflake-connector-python psutil==7.2.2 # via edx-django-utils pycparser==3.0 @@ -171,18 +190,23 @@ pyjwt[crypto]==2.12.1 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # snowflake-connector-python pymemcache==4.0.0 # via -r requirements/base.in pymongo==4.16.0 # via edx-opaque-keys pynacl==1.6.2 # via edx-django-utils +pyopenssl==26.2.0 + # via snowflake-connector-python python-dateutil==2.9.0.post0 # via botocore python-memcached==1.62 # via -r requirements/base.in pytz==2026.1.post1 - # via drf-yasg + # via + # drf-yasg + # snowflake-connector-python pyyaml==6.0.3 # via # -r requirements/production.in @@ -193,6 +217,7 @@ requests==2.33.1 # edx-drf-extensions # edx-enterprise-data # edx-rest-api-client + # snowflake-connector-python rules==3.5 # via edx-enterprise-data s3transfer==0.16.0 @@ -206,18 +231,28 @@ six==1.17.0 # edx-rbac # html5lib # python-dateutil +snowflake-connector-python==4.7.1 + # via + # -r requirements/base.in + # edx-enterprise-data +sortedcontainers==2.4.0 + # via snowflake-connector-python sqlparse==0.5.5 # via django stevedore==5.7.0 # via # edx-django-utils # edx-opaque-keys +tomlkit==0.15.1 + # via snowflake-connector-python tqdm==4.67.3 # via -r requirements/base.in typing-extensions==4.15.0 # via # django-countries # edx-opaque-keys + # pyopenssl + # snowflake-connector-python uritemplate==4.2.0 # via drf-yasg urllib3==1.26.20 diff --git a/requirements/test.txt b/requirements/test.txt index 4d73a891..e54cfb63 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -2,25 +2,32 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# make upgrade +# pip-compile --output-file=requirements/test.txt requirements/test.in # asgiref==3.11.1 # via # django # django-cors-headers # django-countries +asn1crypto==1.5.1 + # via snowflake-connector-python astroid==3.3.11 # via pylint boto==2.49.0 # via -r requirements/base.in boto3==1.42.90 - # via -r requirements/base.in + # via + # -r requirements/base.in + # snowflake-connector-python botocore==1.42.90 # via # boto3 # s3transfer + # snowflake-connector-python certifi==2026.2.25 - # via requests + # via + # requests + # snowflake-connector-python cffi==2.0.0 # via # cryptography @@ -28,7 +35,9 @@ cffi==2.0.0 chardet==7.4.3 # via diff-cover charset-normalizer==3.4.7 - # via requests + # via + # requests + # snowflake-connector-python click==8.3.2 # via edx-django-utils coverage[toml]==7.13.5 @@ -37,14 +46,18 @@ coverage[toml]==7.13.5 # pytest-cov cryptography==46.0.7 # via + # -r requirements/base.in # django-fernet-fields-v2 # pyjwt + # pyopenssl + # snowflake-connector-python ddt==1.7.2 # via -r requirements/test.in diff-cover==10.2.0 # via -r requirements/test.in dill==0.4.1 # via pylint +django==4.2.30 # via # -c requirements/constraints.txt # -r requirements/base.in @@ -145,12 +158,16 @@ factory-boy==3.3.3 # via edx-enterprise-data faker==40.13.0 # via factory-boy +filelock==3.32.0 + # via snowflake-connector-python freezegun==1.5.5 # via -r requirements/test.in html5lib==1.1 # via -r requirements/base.in idna==3.11 - # via requests + # via + # requests + # snowflake-connector-python inflection==0.5.1 # via drf-yasg iniconfig==2.3.0 @@ -177,8 +194,11 @@ packaging==26.1 # via # drf-yasg # pytest + # snowflake-connector-python platformdirs==4.9.6 - # via pylint + # via + # pylint + # snowflake-connector-python pluggy==1.6.0 # via # diff-cover @@ -201,6 +221,7 @@ pyjwt[crypto]==2.12.1 # drf-jwt # edx-drf-extensions # edx-rest-api-client + # snowflake-connector-python pylint==3.3.8 # via # -c requirements/constraints.txt @@ -211,6 +232,8 @@ pymongo==4.16.0 # via edx-opaque-keys pynacl==1.6.2 # via edx-django-utils +pyopenssl==26.2.0 + # via snowflake-connector-python pytest==9.0.3 # via # pytest-cov @@ -229,6 +252,7 @@ pytz==2026.1.post1 # via # -r requirements/test.in # drf-yasg + # snowflake-connector-python pyyaml==6.0.3 # via # drf-yasg @@ -240,6 +264,7 @@ requests==2.33.1 # edx-enterprise-data # edx-rest-api-client # responses + # snowflake-connector-python responses==0.26.0 # via -r requirements/test.in rules==3.5 @@ -257,6 +282,12 @@ six==1.17.0 # python-dateutil snowballstemmer==3.0.1 # via pydocstyle +snowflake-connector-python==4.7.1 + # via + # -r requirements/base.in + # edx-enterprise-data +sortedcontainers==2.4.0 + # via snowflake-connector-python sqlparse==0.5.5 # via django stevedore==5.7.0 @@ -264,13 +295,17 @@ stevedore==5.7.0 # edx-django-utils # edx-opaque-keys tomlkit==0.14.0 - # via pylint + # via + # pylint + # snowflake-connector-python tqdm==4.67.3 # via -r requirements/base.in typing-extensions==4.15.0 # via # django-countries # edx-opaque-keys + # pyopenssl + # snowflake-connector-python uritemplate==4.2.0 # via drf-yasg urllib3==1.26.20