megaton 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. megaton-1.0.0/LICENSE +21 -0
  2. megaton-1.0.0/MANIFEST.in +3 -0
  3. megaton-1.0.0/PKG-INFO +101 -0
  4. megaton-1.0.0/README.md +65 -0
  5. megaton-1.0.0/megaton/__init__.py +62 -0
  6. megaton-1.0.0/megaton/auth/__init__.py +6 -0
  7. megaton-1.0.0/megaton/auth/google_auth.py +231 -0
  8. megaton-1.0.0/megaton/auth/provider.py +117 -0
  9. megaton-1.0.0/megaton/bq.py +479 -0
  10. megaton-1.0.0/megaton/constants.py +17 -0
  11. megaton-1.0.0/megaton/dates.py +210 -0
  12. megaton-1.0.0/megaton/errors.py +109 -0
  13. megaton-1.0.0/megaton/files.py +51 -0
  14. megaton-1.0.0/megaton/ga3.py +796 -0
  15. megaton-1.0.0/megaton/ga4.py +1063 -0
  16. megaton-1.0.0/megaton/gdrive.py +21 -0
  17. megaton-1.0.0/megaton/google_api.py +124 -0
  18. megaton-1.0.0/megaton/gsheet.py +369 -0
  19. megaton-1.0.0/megaton/install/__init__.py +0 -0
  20. megaton-1.0.0/megaton/install/install_bigquery.py +9 -0
  21. megaton-1.0.0/megaton/install/install_ga4.py +10 -0
  22. megaton-1.0.0/megaton/os.py +19 -0
  23. megaton-1.0.0/megaton/recipes/__init__.py +5 -0
  24. megaton-1.0.0/megaton/recipes/config_loader.py +84 -0
  25. megaton-1.0.0/megaton/searchconsole.py +50 -0
  26. megaton-1.0.0/megaton/services/__init__.py +1 -0
  27. megaton-1.0.0/megaton/services/bq_service.py +20 -0
  28. megaton-1.0.0/megaton/services/gsc_service.py +382 -0
  29. megaton-1.0.0/megaton/services/sheets_service.py +448 -0
  30. megaton-1.0.0/megaton/start.py +3456 -0
  31. megaton-1.0.0/megaton/state.py +18 -0
  32. megaton-1.0.0/megaton/transform/__init__.py +28 -0
  33. megaton-1.0.0/megaton/transform/classify.py +43 -0
  34. megaton-1.0.0/megaton/transform/ga4.py +329 -0
  35. megaton-1.0.0/megaton/transform/table.py +114 -0
  36. megaton-1.0.0/megaton/transform/text.py +152 -0
  37. megaton-1.0.0/megaton/ui/__init__.py +1 -0
  38. megaton-1.0.0/megaton/ui/widgets.py +149 -0
  39. megaton-1.0.0/megaton/utils.py +206 -0
  40. megaton-1.0.0/megaton.egg-info/PKG-INFO +101 -0
  41. megaton-1.0.0/megaton.egg-info/SOURCES.txt +84 -0
  42. megaton-1.0.0/megaton.egg-info/dependency_links.txt +1 -0
  43. megaton-1.0.0/megaton.egg-info/requires.txt +14 -0
  44. megaton-1.0.0/megaton.egg-info/top_level.txt +1 -0
  45. megaton-1.0.0/requirements.txt +14 -0
  46. megaton-1.0.0/setup.cfg +4 -0
  47. megaton-1.0.0/setup.py +39 -0
  48. megaton-1.0.0/tests/test_auth.py +292 -0
  49. megaton-1.0.0/tests/test_auth_provider.py +114 -0
  50. megaton-1.0.0/tests/test_auto_install.py +97 -0
  51. megaton-1.0.0/tests/test_clean_parameter.py +93 -0
  52. megaton-1.0.0/tests/test_config_loader.py +131 -0
  53. megaton-1.0.0/tests/test_dates.py +125 -0
  54. megaton-1.0.0/tests/test_ga4_property_show.py +50 -0
  55. megaton-1.0.0/tests/test_ga4_report_core.py +165 -0
  56. megaton-1.0.0/tests/test_ga4_report_retry.py +75 -0
  57. megaton-1.0.0/tests/test_gsc_service.py +189 -0
  58. megaton-1.0.0/tests/test_gsheet_core.py +287 -0
  59. megaton-1.0.0/tests/test_gsheet_timeout.py +54 -0
  60. megaton-1.0.0/tests/test_megaton_auth_bridge.py +83 -0
  61. megaton-1.0.0/tests/test_normalize_queries.py +260 -0
  62. megaton-1.0.0/tests/test_phase0_regressions.py +122 -0
  63. megaton-1.0.0/tests/test_report_dates_to_sheet.py +93 -0
  64. megaton-1.0.0/tests/test_report_month_window.py +25 -0
  65. megaton-1.0.0/tests/test_report_prep.py +39 -0
  66. megaton-1.0.0/tests/test_report_result.py +646 -0
  67. megaton-1.0.0/tests/test_report_run_multi.py +221 -0
  68. megaton-1.0.0/tests/test_report_show_download.py +127 -0
  69. megaton-1.0.0/tests/test_run_all.py +1207 -0
  70. megaton-1.0.0/tests/test_sc_wrapper.py +162 -0
  71. megaton-1.0.0/tests/test_search_apply_if.py +182 -0
  72. megaton-1.0.0/tests/test_search_dimension_filter.py +158 -0
  73. megaton-1.0.0/tests/test_search_filter.py +81 -0
  74. megaton-1.0.0/tests/test_search_result.py +380 -0
  75. megaton-1.0.0/tests/test_sheet_namespace.py +216 -0
  76. megaton-1.0.0/tests/test_sheet_save_options.py +107 -0
  77. megaton-1.0.0/tests/test_sheets_service.py +656 -0
  78. megaton-1.0.0/tests/test_transform_classify.py +23 -0
  79. megaton-1.0.0/tests/test_transform_ga4.py +212 -0
  80. megaton-1.0.0/tests/test_transform_pipeline_gsc_like.py +84 -0
  81. megaton-1.0.0/tests/test_transform_table.py +47 -0
  82. megaton-1.0.0/tests/test_transform_table_normalize_thresholds_df.py +58 -0
  83. megaton-1.0.0/tests/test_transform_text.py +118 -0
  84. megaton-1.0.0/tests/test_ui_lazy_import.py +87 -0
  85. megaton-1.0.0/tests/test_upsert_to_csv.py +108 -0
  86. megaton-1.0.0/tests/test_utils.py +66 -0
megaton-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Mak Shimizu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ include README.md
2
+ include LICENSE
3
+ include requirements.txt
megaton-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: megaton
3
+ Version: 1.0.0
4
+ Summary: Utilities for Google Analytics, Google Analytics 4, Google Sheets, Search Console and Google Cloud Platform.
5
+ Author: Makoto Shimizu
6
+ Author-email: aa.analyst.ga@gmail.com
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Topic :: Internet
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: pandas>=1.3
14
+ Requires-Dist: google-api-python-client>=2.89
15
+ Requires-Dist: google-auth>=2.40.0
16
+ Requires-Dist: google-api-core>=2.25.0
17
+ Requires-Dist: google-auth-oauthlib>=1.0
18
+ Requires-Dist: google-analytics-data>=0.18
19
+ Requires-Dist: google-analytics-admin>=0.25.0
20
+ Requires-Dist: google-cloud-bigquery>=3.11
21
+ Requires-Dist: google-cloud-bigquery-datatransfer>=1.11
22
+ Requires-Dist: gspread>=5.7
23
+ Requires-Dist: oauthlib>=3.2
24
+ Requires-Dist: ipywidgets>=8.0
25
+ Requires-Dist: gspread-dataframe>=3.0
26
+ Requires-Dist: pytz>=2023.3
27
+ Dynamic: author
28
+ Dynamic: author-email
29
+ Dynamic: classifier
30
+ Dynamic: description
31
+ Dynamic: description-content-type
32
+ Dynamic: license-file
33
+ Dynamic: requires-dist
34
+ Dynamic: requires-python
35
+ Dynamic: summary
36
+
37
+ # megaton
38
+
39
+ Megaton は Google Analytics 4、Google Search Console、Google Sheets、BigQuery を **Notebook から短いコードで扱う** ためのツールです。分析の試行錯誤を速く回すことを重視し、Notebook 向けの UX に特化しています。
40
+
41
+ ## コア概念
42
+
43
+ - **結果オブジェクト**: `SearchResult` / `ReportResult` によるメソッドチェーン
44
+ - **シンプルな流れ**: 開く → 期間設定 → 取得 → 保存
45
+ - **Notebook 前提**: 途中結果の確認を前提にした設計
46
+
47
+ ## クイックスタート
48
+
49
+ ```python
50
+ from megaton.start import Megaton
51
+
52
+ mg = Megaton("/path/to/service_account.json")
53
+ mg.report.set.dates("2024-01-01", "2024-01-31")
54
+ mg.report.run(d=["date", "eventName"], m=["eventCount"])
55
+
56
+ mg.open.sheet("https://docs.google.com/spreadsheets/d/...")
57
+ mg.save.to.sheet("_ga_data", mg.report.data)
58
+ ```
59
+
60
+ ## もう少し実用的な例
61
+
62
+ ```python
63
+ # 複数サイトの Search Console データを一括取得して整形
64
+ result = (mg.search.run.all(
65
+ sites,
66
+ dimensions=['query', 'page'],
67
+ item_key='clinic',
68
+ )
69
+ .categorize('query', by=query_map)
70
+ .categorize('page', by=page_map))
71
+
72
+ mg.save.to.sheet('_query', result.df, sort_by='impressions')
73
+ mg.upsert.to.csv(result.df, filename='query_master', keys=['clinic', 'query', 'page'], include_dates=False)
74
+ ```
75
+
76
+ ## インストール
77
+
78
+ ```bash
79
+ # PyPI 公開版
80
+ pip install megaton
81
+
82
+ # 最新版(GitHub)
83
+ pip install git+https://github.com/mak00s/megaton.git
84
+ ```
85
+
86
+ ## ドキュメント
87
+
88
+ - 仕様の正は [api-reference.md](docs/api-reference.md) です。
89
+ - [api-reference.md](docs/api-reference.md) - API 仕様の単一ソース
90
+ - [design.md](docs/design.md) - 設計思想とトレードオフ
91
+ - [cookbook.md](docs/cookbook.md) - 実用例集
92
+ - [cheatsheet.md](docs/cheatsheet.md) - 1 行リファレンス
93
+
94
+ ## 変更履歴
95
+
96
+ - [CHANGELOG.md](CHANGELOG.md)
97
+ - [docs/changelog-archive.md](docs/changelog-archive.md) - 0.x 系の履歴
98
+
99
+ ## ライセンス
100
+
101
+ MIT License
@@ -0,0 +1,65 @@
1
+ # megaton
2
+
3
+ Megaton は Google Analytics 4、Google Search Console、Google Sheets、BigQuery を **Notebook から短いコードで扱う** ためのツールです。分析の試行錯誤を速く回すことを重視し、Notebook 向けの UX に特化しています。
4
+
5
+ ## コア概念
6
+
7
+ - **結果オブジェクト**: `SearchResult` / `ReportResult` によるメソッドチェーン
8
+ - **シンプルな流れ**: 開く → 期間設定 → 取得 → 保存
9
+ - **Notebook 前提**: 途中結果の確認を前提にした設計
10
+
11
+ ## クイックスタート
12
+
13
+ ```python
14
+ from megaton.start import Megaton
15
+
16
+ mg = Megaton("/path/to/service_account.json")
17
+ mg.report.set.dates("2024-01-01", "2024-01-31")
18
+ mg.report.run(d=["date", "eventName"], m=["eventCount"])
19
+
20
+ mg.open.sheet("https://docs.google.com/spreadsheets/d/...")
21
+ mg.save.to.sheet("_ga_data", mg.report.data)
22
+ ```
23
+
24
+ ## もう少し実用的な例
25
+
26
+ ```python
27
+ # 複数サイトの Search Console データを一括取得して整形
28
+ result = (mg.search.run.all(
29
+ sites,
30
+ dimensions=['query', 'page'],
31
+ item_key='clinic',
32
+ )
33
+ .categorize('query', by=query_map)
34
+ .categorize('page', by=page_map))
35
+
36
+ mg.save.to.sheet('_query', result.df, sort_by='impressions')
37
+ mg.upsert.to.csv(result.df, filename='query_master', keys=['clinic', 'query', 'page'], include_dates=False)
38
+ ```
39
+
40
+ ## インストール
41
+
42
+ ```bash
43
+ # PyPI 公開版
44
+ pip install megaton
45
+
46
+ # 最新版(GitHub)
47
+ pip install git+https://github.com/mak00s/megaton.git
48
+ ```
49
+
50
+ ## ドキュメント
51
+
52
+ - 仕様の正は [api-reference.md](docs/api-reference.md) です。
53
+ - [api-reference.md](docs/api-reference.md) - API 仕様の単一ソース
54
+ - [design.md](docs/design.md) - 設計思想とトレードオフ
55
+ - [cookbook.md](docs/cookbook.md) - 実用例集
56
+ - [cheatsheet.md](docs/cheatsheet.md) - 1 行リファレンス
57
+
58
+ ## 変更履歴
59
+
60
+ - [CHANGELOG.md](CHANGELOG.md)
61
+ - [docs/changelog-archive.md](docs/changelog-archive.md) - 0.x 系の履歴
62
+
63
+ ## ライセンス
64
+
65
+ MIT License
@@ -0,0 +1,62 @@
1
+ import os
2
+ import sys
3
+ from IPython.display import clear_output
4
+
5
+
6
+ __all__ = ['start', 'mount_google_drive']
7
+
8
+
9
+ def _is_colab() -> bool:
10
+ return 'google.colab' in sys.modules
11
+
12
+
13
+ def _auto_install_enabled() -> bool:
14
+ env_value = os.environ.get("MEGATON_AUTO_INSTALL")
15
+ if env_value == "1":
16
+ return True
17
+ if env_value == "0":
18
+ return False
19
+ return _is_colab()
20
+
21
+
22
+ def _print_install_help():
23
+ print(
24
+ "Megaton requires GA4 packages. Install with:\n"
25
+ " pip install -U -q google-analytics-admin google-analytics-data\n"
26
+ " pip install -U -q google-cloud-bigquery-datatransfer\n"
27
+ "Or set MEGATON_AUTO_INSTALL=1 in Colab."
28
+ )
29
+
30
+
31
+ try:
32
+ # check if packages for GA4 are installed
33
+ from google.analytics.data import BetaAnalyticsDataClient
34
+ from google.analytics.admin import AnalyticsAdminServiceClient
35
+ except ModuleNotFoundError:
36
+ if _auto_install_enabled():
37
+ clear_output()
38
+ print("Installing packages for GA4...")
39
+ from .install import install_ga4, install_bigquery
40
+
41
+ install_ga4.install()
42
+ install_bigquery.install()
43
+ clear_output()
44
+ else:
45
+ _print_install_help()
46
+ raise
47
+
48
+ IS_COLAB = _is_colab()
49
+
50
+ if IS_COLAB:
51
+ from google.colab import data_table
52
+ data_table.enable_dataframe_formatter()
53
+ data_table._DEFAULT_FORMATTERS[float] = lambda x: f"{x:.3f}"
54
+
55
+
56
+ def mount_google_drive():
57
+ '''Mount Google Drive when running in Google Colab.'''
58
+ if not IS_COLAB:
59
+ print("Google Drive mounting is only available in Google Colab.")
60
+ return None
61
+ from . import gdrive
62
+ return gdrive.link_nbs()
@@ -0,0 +1,6 @@
1
+ """Auth helpers."""
2
+
3
+ from . import google_auth, provider
4
+ from .google_auth import * # noqa: F403
5
+
6
+ __all__ = list(google_auth.__all__) + ["google_auth", "provider"]
@@ -0,0 +1,231 @@
1
+ """
2
+ Functions for handling Authentications
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from collections import defaultdict
9
+
10
+ import google.oauth2.credentials
11
+ from google.oauth2 import service_account
12
+ from google.oauth2.credentials import Credentials
13
+ from google_auth_oauthlib.flow import InstalledAppFlow
14
+
15
+ _REQUIRED_CONFIG_KEYS = frozenset(("auth_uri", "token_uri", "client_id"))
16
+ LOGGER = logging.getLogger(__name__)
17
+
18
+
19
+ def _is_service_account(json_text: str):
20
+ """Return true if the provided text is a JSON service credentials file."""
21
+ try:
22
+ key_obj = json.loads(json_text)
23
+ except json.JSONDecodeError:
24
+ return False
25
+ if not key_obj or key_obj.get('type', '') != 'service_account':
26
+ return False
27
+ return True
28
+
29
+
30
+ def _is_service_account_json(json_path: str):
31
+ """Return true if the provided JSON file is for a service account."""
32
+ with open(json_path, 'r') as f:
33
+ return _is_service_account(f.read())
34
+
35
+
36
+ def get_credential_type(client_config: dict):
37
+ """Gets a client type from client configuration loaded from a Google-format client secrets file.
38
+
39
+ Args:
40
+ client_config (Mapping[str, Any]): The client
41
+ configuration in the Google `client secrets`_ format.
42
+
43
+ Returns:
44
+ client_type [str]: The client type, either ``'service_account'`` or ``'web'`` or ``'installed'``
45
+ """
46
+ if client_config.get('type', '') == "service_account":
47
+ return "service_account"
48
+ elif "web" in client_config:
49
+ client_type = "web"
50
+ elif "installed" in client_config:
51
+ client_type = "installed"
52
+ else:
53
+ return None
54
+ config = client_config[client_type]
55
+ if _REQUIRED_CONFIG_KEYS.issubset(config.keys()):
56
+ return client_type
57
+
58
+
59
+ def get_credential_type_from_file(json_path: str):
60
+ """Gets a client type from a Google client secrets file.
61
+
62
+ Args:
63
+ json_path (str): The path to the client secrets .json file.
64
+
65
+ Returns:
66
+ client_type [str]: The client type, either ``'service_account'`` or ``'web'`` or ``'installed'``
67
+ """
68
+ try:
69
+ with open(json_path, "r") as fp:
70
+ client_config = json.load(fp)
71
+ except (OSError, json.JSONDecodeError):
72
+ LOGGER.debug("Skipping non JSON credential file: %s", json_path)
73
+ return None
74
+
75
+ if not isinstance(client_config, dict):
76
+ LOGGER.debug('Credential file %s does not contain a JSON object; skipping.', json_path)
77
+ return None
78
+
79
+ return get_credential_type(client_config)
80
+
81
+
82
+ def get_credential_type_from_info(info: dict) -> str:
83
+ if isinstance(info, dict):
84
+ if info.get("type") == "service_account":
85
+ return "service_account"
86
+ if "installed" in info:
87
+ return "installed"
88
+ if "web" in info:
89
+ return "web"
90
+ return "unknown"
91
+
92
+
93
+ def get_json_files_from_dir(json_dir: str):
94
+ """Gets a list of valid credentials json files from a directory recursively"""
95
+ json_files = defaultdict(lambda: {})
96
+ for root, dirs, files in os.walk(json_dir):
97
+ for file in files:
98
+ if file.endswith('.json'):
99
+ client_type = get_credential_type_from_file(os.path.join(root, file))
100
+ if client_type == 'service_account':
101
+ json_files['Service Account'][file] = os.path.join(root, file)
102
+ elif client_type in ['installed', 'web']:
103
+ json_files['OAuth'][file] = os.path.join(root, file)
104
+ return json_files
105
+
106
+
107
+ def get_cache_path(json_path: str):
108
+ """Gets the path to the Google user credentials based on the provided source file
109
+ """
110
+ dir_path = os.path.join(os.path.expanduser("~"), ".config")
111
+ os.makedirs(dir_path, exist_ok=True)
112
+ base_name = os.path.splitext(os.path.basename(json_path))[0]
113
+ return os.path.join(dir_path, f"cache_{base_name}.json")
114
+
115
+
116
+ def save_credentials(file_path: str, credentials: Credentials):
117
+ """Save Credentials to cache file
118
+ """
119
+ cache_path = get_cache_path(file_path)
120
+ with open(cache_path, 'w') as w:
121
+ LOGGER.debug(f"saving credentials to {cache_path}")
122
+ w.write(credentials.to_json())
123
+ return credentials
124
+
125
+
126
+ def load_credentials(file_path: str, scopes: list):
127
+ """Load Credentials from cache file
128
+ """
129
+ cache_path = get_cache_path(file_path)
130
+ if os.path.isfile(cache_path):
131
+ LOGGER.debug(f"loading credentials from {cache_path}")
132
+ return Credentials.from_authorized_user_file(cache_path, scopes=scopes)
133
+
134
+
135
+ def load_service_account_credentials_from_info(info: dict, scopes: list):
136
+ if not isinstance(info, dict) or info.get("type") != "service_account":
137
+ raise ValueError("service_account info required")
138
+ credentials = service_account.Credentials.from_service_account_info(info, scopes=scopes)
139
+ if not credentials.valid:
140
+ request = google.auth.transport.requests.Request()
141
+ try:
142
+ credentials.refresh(request)
143
+ except google.auth.exceptions.RefreshError as exc:
144
+ email = info.get("client_email") or getattr(credentials, "service_account_email", None)
145
+ if email:
146
+ message = f"指定の {email} のサービスアカウントは存在しない、または無効です。"
147
+ else:
148
+ message = "指定したサービスアカウントは存在しない、または無効です。"
149
+ LOGGER.error(message)
150
+ LOGGER.debug(f"Service account refresh error detail: {exc}")
151
+ return None
152
+ return credentials
153
+
154
+
155
+ def delete_credentials(cache_file: str = "creden-cache.json"):
156
+ """Delete Credentials cache file
157
+ """
158
+ if os.path.isfile(cache_file):
159
+ LOGGER.debug(f"deleting cache file {cache_file}")
160
+ os.remove(cache_file)
161
+
162
+
163
+ def get_oauth_redirect(client_secret_file: str, scopes: list):
164
+ """Run OAuth2 Flow"""
165
+ flow = InstalledAppFlow.from_client_secrets_file(
166
+ client_secret_file,
167
+ scopes=scopes,
168
+ redirect_uri="urn:ietf:wg:oauth:2.0:oob"
169
+ )
170
+ auth_url, _ = flow.authorization_url(prompt="consent")
171
+ return flow, auth_url
172
+
173
+
174
+ def get_oauth_redirect_from_info(info: dict, scopes: list):
175
+ # mirrors get_oauth_redirect(file, scopes) but uses in-memory client config
176
+ flow = InstalledAppFlow.from_client_config(info, scopes=scopes)
177
+ flow.redirect_uri = "urn:ietf:wg:oauth:2.0:oob"
178
+ auth_url, _ = flow.authorization_url(
179
+ prompt='consent',
180
+ access_type='offline',
181
+ include_granted_scopes='true'
182
+ )
183
+ return flow, auth_url
184
+
185
+
186
+ def get_token(flow, code: str):
187
+ flow.fetch_token(code=code)
188
+ return flow.credentials
189
+
190
+
191
+ def load_service_account_credentials_from_file(path: str, scopes: list):
192
+ """Gets service account credentials from JSON file at ``path``.
193
+
194
+ :param path: Path to credentials JSON file.
195
+ :param scopes: A list of scopes to use when authenticating to Google APIs.
196
+ :return: google.oauth2.service_account.Credentials
197
+ """
198
+ credentials = service_account.Credentials.from_service_account_file(path, scopes=scopes)
199
+ if not credentials.valid:
200
+ request = google.auth.transport.requests.Request()
201
+ try:
202
+ credentials.refresh(request)
203
+ except google.auth.exceptions.RefreshError as exc:
204
+ email = getattr(credentials, "service_account_email", None)
205
+ if email:
206
+ message = f"指定の {email} のサービスアカウントは存在しない、または無効です。"
207
+ else:
208
+ message = "指定したサービスアカウントは存在しない、または無効です。"
209
+ LOGGER.error(message)
210
+ LOGGER.debug("Service account refresh error detail: %s", exc)
211
+ return None
212
+ return credentials
213
+
214
+
215
+ __all__ = [
216
+ "_is_service_account",
217
+ "_is_service_account_json",
218
+ "get_credential_type",
219
+ "get_credential_type_from_file",
220
+ "get_credential_type_from_info",
221
+ "get_json_files_from_dir",
222
+ "get_cache_path",
223
+ "save_credentials",
224
+ "load_credentials",
225
+ "load_service_account_credentials_from_info",
226
+ "delete_credentials",
227
+ "get_oauth_redirect",
228
+ "get_oauth_redirect_from_info",
229
+ "get_token",
230
+ "load_service_account_credentials_from_file",
231
+ ]
@@ -0,0 +1,117 @@
1
+ """Credential source resolution helpers."""
2
+
3
+ from dataclasses import dataclass
4
+ import base64
5
+ import json
6
+ import os
7
+ from typing import Any, Optional, Tuple
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class CredentialSource:
12
+ raw: Any
13
+ origin: str
14
+ kind: str
15
+ value: Optional[str] = None
16
+ info: Optional[dict] = None
17
+ credential_type: Optional[str] = None
18
+ error: Optional[Exception] = None
19
+
20
+
21
+ def get_credential_type_from_info(info: Optional[dict]) -> str:
22
+ from . import google_auth
23
+
24
+ return google_auth.get_credential_type_from_info(info)
25
+
26
+
27
+ def parse_json_input(value: Any) -> Optional[dict]:
28
+ """Return dict if value looks like JSON (or base64 JSON); else None."""
29
+ if isinstance(value, dict):
30
+ return value
31
+ if not isinstance(value, str):
32
+ return None
33
+ s = value.strip()
34
+ if s.startswith("{") and s.endswith("}"):
35
+ try:
36
+ return json.loads(s)
37
+ except Exception:
38
+ return None
39
+ try:
40
+ decoded = base64.b64decode(s).decode("utf-8", errors="ignore")
41
+ ds = decoded.strip()
42
+ if ds.startswith("{") and ds.endswith("}"):
43
+ return json.loads(ds)
44
+ except Exception:
45
+ pass
46
+ return None
47
+
48
+
49
+ def load_json_file(path: str) -> Tuple[Optional[dict], Optional[Exception]]:
50
+ try:
51
+ with open(path) as fp:
52
+ data = json.load(fp)
53
+ except Exception as exc:
54
+ return None, exc
55
+ if not isinstance(data, dict):
56
+ return None, ValueError("JSON object required")
57
+ return data, None
58
+
59
+
60
+ def extract_email_from_file(path: str) -> Optional[str]:
61
+ info, _ = load_json_file(path)
62
+ if not isinstance(info, dict):
63
+ return None
64
+ return info.get("client_email")
65
+
66
+
67
+ def resolve_credential_source(
68
+ credential: Any,
69
+ *,
70
+ env_var: str = "MEGATON_CREDS_JSON",
71
+ in_colab: bool = False,
72
+ colab_default: str = "/nbs",
73
+ ) -> CredentialSource:
74
+ origin = "explicit"
75
+ raw = credential
76
+ value = credential
77
+ if credential is None:
78
+ env_val = os.environ.get(env_var)
79
+ if env_val:
80
+ origin = "env"
81
+ value = env_val
82
+ elif in_colab:
83
+ origin = "colab_default"
84
+ value = colab_default
85
+ else:
86
+ return CredentialSource(raw=None, origin="none", kind="none")
87
+
88
+ if isinstance(value, dict):
89
+ info = value
90
+ ctype = get_credential_type_from_info(info)
91
+ return CredentialSource(raw=raw, origin=origin, kind="inline", info=info, credential_type=ctype)
92
+
93
+ if not isinstance(value, str):
94
+ return CredentialSource(raw=raw, origin=origin, kind="unknown")
95
+
96
+ info = parse_json_input(value)
97
+ if info is not None:
98
+ ctype = get_credential_type_from_info(info)
99
+ return CredentialSource(raw=raw, origin=origin, kind="inline", value=value, info=info, credential_type=ctype)
100
+
101
+ if os.path.isdir(value):
102
+ return CredentialSource(raw=raw, origin=origin, kind="directory", value=value)
103
+
104
+ if os.path.isfile(value):
105
+ info, error = load_json_file(value)
106
+ ctype = get_credential_type_from_info(info) if info else None
107
+ return CredentialSource(
108
+ raw=raw,
109
+ origin=origin,
110
+ kind="file",
111
+ value=value,
112
+ info=info,
113
+ credential_type=ctype,
114
+ error=error,
115
+ )
116
+
117
+ return CredentialSource(raw=raw, origin=origin, kind="unknown", value=value)