esgpull 0.6.3__py3-none-any.whl

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 (80) hide show
  1. esgpull/__init__.py +12 -0
  2. esgpull/auth.py +181 -0
  3. esgpull/cli/__init__.py +73 -0
  4. esgpull/cli/add.py +103 -0
  5. esgpull/cli/autoremove.py +38 -0
  6. esgpull/cli/config.py +116 -0
  7. esgpull/cli/convert.py +285 -0
  8. esgpull/cli/decorators.py +342 -0
  9. esgpull/cli/download.py +74 -0
  10. esgpull/cli/facet.py +23 -0
  11. esgpull/cli/get.py +28 -0
  12. esgpull/cli/install.py +85 -0
  13. esgpull/cli/link.py +105 -0
  14. esgpull/cli/login.py +56 -0
  15. esgpull/cli/remove.py +73 -0
  16. esgpull/cli/retry.py +43 -0
  17. esgpull/cli/search.py +201 -0
  18. esgpull/cli/self.py +238 -0
  19. esgpull/cli/show.py +66 -0
  20. esgpull/cli/status.py +67 -0
  21. esgpull/cli/track.py +87 -0
  22. esgpull/cli/update.py +184 -0
  23. esgpull/cli/utils.py +247 -0
  24. esgpull/config.py +410 -0
  25. esgpull/constants.py +56 -0
  26. esgpull/context.py +724 -0
  27. esgpull/database.py +161 -0
  28. esgpull/download.py +162 -0
  29. esgpull/esgpull.py +447 -0
  30. esgpull/exceptions.py +167 -0
  31. esgpull/fs.py +253 -0
  32. esgpull/graph.py +460 -0
  33. esgpull/install_config.py +185 -0
  34. esgpull/migrations/README +1 -0
  35. esgpull/migrations/env.py +82 -0
  36. esgpull/migrations/script.py.mako +24 -0
  37. esgpull/migrations/versions/0.3.0_update_tables.py +170 -0
  38. esgpull/migrations/versions/0.3.1_update_tables.py +25 -0
  39. esgpull/migrations/versions/0.3.2_update_tables.py +26 -0
  40. esgpull/migrations/versions/0.3.3_update_tables.py +25 -0
  41. esgpull/migrations/versions/0.3.4_update_tables.py +25 -0
  42. esgpull/migrations/versions/0.3.5_update_tables.py +25 -0
  43. esgpull/migrations/versions/0.3.6_update_tables.py +26 -0
  44. esgpull/migrations/versions/0.3.7_update_tables.py +26 -0
  45. esgpull/migrations/versions/0.3.8_update_tables.py +26 -0
  46. esgpull/migrations/versions/0.4.0_update_tables.py +25 -0
  47. esgpull/migrations/versions/0.5.0_update_tables.py +26 -0
  48. esgpull/migrations/versions/0.5.1_update_tables.py +26 -0
  49. esgpull/migrations/versions/0.5.2_update_tables.py +25 -0
  50. esgpull/migrations/versions/0.5.3_update_tables.py +26 -0
  51. esgpull/migrations/versions/0.5.4_update_tables.py +25 -0
  52. esgpull/migrations/versions/0.5.5_update_tables.py +25 -0
  53. esgpull/migrations/versions/0.6.0_update_tables.py +25 -0
  54. esgpull/migrations/versions/0.6.1_update_tables.py +25 -0
  55. esgpull/migrations/versions/0.6.2_update_tables.py +25 -0
  56. esgpull/migrations/versions/0.6.3_update_tables.py +25 -0
  57. esgpull/models/__init__.py +31 -0
  58. esgpull/models/base.py +50 -0
  59. esgpull/models/dataset.py +34 -0
  60. esgpull/models/facet.py +18 -0
  61. esgpull/models/file.py +65 -0
  62. esgpull/models/options.py +164 -0
  63. esgpull/models/query.py +481 -0
  64. esgpull/models/selection.py +201 -0
  65. esgpull/models/sql.py +258 -0
  66. esgpull/models/synda_file.py +85 -0
  67. esgpull/models/tag.py +19 -0
  68. esgpull/models/utils.py +54 -0
  69. esgpull/presets.py +13 -0
  70. esgpull/processor.py +172 -0
  71. esgpull/py.typed +0 -0
  72. esgpull/result.py +53 -0
  73. esgpull/tui.py +346 -0
  74. esgpull/utils.py +54 -0
  75. esgpull/version.py +1 -0
  76. esgpull-0.6.3.dist-info/METADATA +110 -0
  77. esgpull-0.6.3.dist-info/RECORD +80 -0
  78. esgpull-0.6.3.dist-info/WHEEL +4 -0
  79. esgpull-0.6.3.dist-info/entry_points.txt +3 -0
  80. esgpull-0.6.3.dist-info/licenses/LICENSE +28 -0
esgpull/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ from esgpull.context import Context
2
+ from esgpull.esgpull import Esgpull
3
+ from esgpull.models import File, Query
4
+ from esgpull.version import __version__
5
+
6
+ __all__ = [
7
+ "Context",
8
+ "Esgpull",
9
+ "File",
10
+ "Query",
11
+ "__version__",
12
+ ]
esgpull/auth.py ADDED
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum, unique
4
+ from pathlib import Path
5
+ from shutil import rmtree
6
+ from typing import Any
7
+ from urllib.parse import (
8
+ ParseResult,
9
+ ParseResultBytes,
10
+ urljoin,
11
+ urlparse,
12
+ urlunparse,
13
+ )
14
+ from xml.etree import ElementTree
15
+
16
+ import httpx
17
+ import tomlkit
18
+ from attrs import Factory, define, field
19
+ from myproxy.client import MyProxyClient
20
+ from OpenSSL import crypto
21
+
22
+ from esgpull.config import Config
23
+ from esgpull.constants import PROVIDERS
24
+
25
+
26
+ class Secret:
27
+ def __init__(self, value: str | None = None) -> None:
28
+ self._value = value
29
+
30
+ def get_value(self) -> str | None:
31
+ return self._value
32
+
33
+ def __str__(self) -> str:
34
+ if self.get_value() is None:
35
+ return str(None)
36
+ else:
37
+ return "*" * 10
38
+
39
+ def __repr__(self) -> str:
40
+ return str(self)
41
+
42
+
43
+ @define
44
+ class Credentials:
45
+ provider: str | None = None
46
+ user: str | None = None
47
+ password: Secret = field(default=None, converter=Secret)
48
+
49
+ @staticmethod
50
+ def from_config(config: Config) -> Credentials:
51
+ path = config.paths.auth / config.credentials.filename
52
+ return Credentials.from_path(path)
53
+
54
+ @staticmethod
55
+ def from_path(path: Path) -> Credentials:
56
+ if path.is_file():
57
+ with path.open() as fh:
58
+ doc = tomlkit.load(fh)
59
+ return Credentials(**doc)
60
+ else:
61
+ return Credentials()
62
+
63
+ def write(self, path: Path) -> None:
64
+ if path.is_file():
65
+ raise FileExistsError(path)
66
+ with path.open("w") as f:
67
+ cred_dict = dict(
68
+ provider=self.provider,
69
+ user=self.user,
70
+ password=self.password.get_value(),
71
+ )
72
+ tomlkit.dump(cred_dict, f)
73
+
74
+ def parse_openid(self) -> ParseResult | ParseResultBytes | Any:
75
+ if self.provider not in PROVIDERS:
76
+ raise ValueError(f"unknown provider: {self.provider}")
77
+ ns = {"x": "xri://$xrd*($v*2.0)"}
78
+ provider = urlunparse(
79
+ [
80
+ "https",
81
+ self.provider,
82
+ urljoin(PROVIDERS[self.provider], self.user),
83
+ "",
84
+ "",
85
+ "",
86
+ ]
87
+ )
88
+ resp = httpx.get(str(provider))
89
+ resp.raise_for_status()
90
+ root = ElementTree.fromstring(resp.text)
91
+ services = root.findall(".//x:Service", namespaces=ns)
92
+ for service in services:
93
+ t = service.find("x:Type", namespaces=ns)
94
+ if t is None:
95
+ continue
96
+ elif t.text == "urn:esg:security:myproxy-service":
97
+ url = service.find("x:URI", namespaces=ns)
98
+ if url is not None:
99
+ return urlparse(url.text)
100
+ raise ValueError("did not found host/port")
101
+
102
+
103
+ @unique
104
+ class AuthStatus(Enum):
105
+ Valid = ("valid", "green")
106
+ Expired = ("expired", "orange")
107
+ Missing = ("missing", "red")
108
+
109
+
110
+ @define
111
+ class Auth:
112
+ cert_dir: Path
113
+ cert_file: Path
114
+ credentials: Credentials = Factory(Credentials)
115
+ __status: AuthStatus | None = field(init=False, default=None, repr=False)
116
+
117
+ Valid = AuthStatus.Valid
118
+ Expired = AuthStatus.Expired
119
+ Missing = AuthStatus.Missing
120
+
121
+ @staticmethod
122
+ def from_config(
123
+ config: Config, credentials: Credentials = Credentials()
124
+ ) -> Auth:
125
+ return Auth.from_path(config.paths.auth, credentials)
126
+
127
+ @staticmethod
128
+ def from_path(
129
+ path: Path, credentials: Credentials = Credentials()
130
+ ) -> Auth:
131
+ cert_dir = path / "certificates"
132
+ cert_file = path / "credentials.pem"
133
+ return Auth(cert_dir, cert_file, credentials)
134
+
135
+ @property
136
+ def cert(self) -> str | None:
137
+ if self.status == AuthStatus.Valid:
138
+ return str(self.cert_file)
139
+ else:
140
+ return None
141
+
142
+ @property
143
+ def status(self) -> AuthStatus:
144
+ if self.__status is None:
145
+ self.__status = self._get_status()
146
+ return self.__status
147
+
148
+ def _get_status(self) -> AuthStatus:
149
+ if not self.cert_file.exists():
150
+ return AuthStatus.Missing
151
+ with self.cert_file.open("rb") as f:
152
+ content = f.read()
153
+ filetype = crypto.FILETYPE_PEM
154
+ pem = crypto.load_certificate(filetype, content)
155
+ if pem.has_expired():
156
+ return AuthStatus.Expired
157
+ return AuthStatus.Valid
158
+
159
+ # TODO: review this
160
+ def renew(self) -> None:
161
+ if self.cert_dir.is_dir():
162
+ rmtree(self.cert_dir)
163
+ self.cert_file.unlink(missing_ok=True)
164
+ openid = self.credentials.parse_openid()
165
+ client = MyProxyClient(
166
+ hostname=openid.hostname,
167
+ port=openid.port,
168
+ caCertDir=str(self.cert_dir),
169
+ proxyCertLifetime=12 * 60 * 60,
170
+ )
171
+ creds = client.logon(
172
+ self.credentials.user,
173
+ self.credentials.password.get_value(),
174
+ bootstrap=True,
175
+ updateTrustRoots=True,
176
+ authnGetTrustRootsCall=False,
177
+ )
178
+ with self.cert_file.open("wb") as file:
179
+ for cred in creds:
180
+ file.write(cred)
181
+ self.__status = None
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # https://click.palletsprojects.com/en/latest/
4
+ import click
5
+
6
+ from esgpull import __version__
7
+ from esgpull.cli.add import add
8
+ from esgpull.cli.config import config
9
+ from esgpull.cli.convert import convert
10
+ from esgpull.cli.download import download
11
+ from esgpull.cli.login import login
12
+ from esgpull.cli.remove import remove
13
+ from esgpull.cli.retry import retry
14
+ from esgpull.cli.search import search
15
+ from esgpull.cli.self import self
16
+ from esgpull.cli.show import show
17
+ from esgpull.cli.status import status
18
+ from esgpull.cli.track import track, untrack
19
+ from esgpull.cli.update import update
20
+ from esgpull.tui import UI
21
+
22
+ # from esgpull.cli.autoremove import autoremove
23
+ # from esgpull.cli.facet import facet
24
+ # from esgpull.cli.get import get
25
+ # from esgpull.cli.install import install
26
+
27
+ # [-]TODO: stats
28
+ # - speed per index/data node
29
+ # - total disk usage
30
+ # - log config for later optimisation ?
31
+
32
+ SUBCOMMANDS: list[click.Command] = [
33
+ add,
34
+ # autoremove,
35
+ config,
36
+ convert,
37
+ download,
38
+ # facet,
39
+ # get,
40
+ self,
41
+ # install,
42
+ login,
43
+ remove,
44
+ retry,
45
+ search,
46
+ show,
47
+ track,
48
+ untrack,
49
+ status,
50
+ # # stats,
51
+ update,
52
+ ]
53
+
54
+ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
55
+
56
+ _ui = UI("/tmp")
57
+ version_msg = _ui.render(f"esgpull, version [green]{__version__}[/]")
58
+
59
+
60
+ @click.group(context_settings=CONTEXT_SETTINGS)
61
+ @click.version_option(None, "-V", "--version", message=version_msg)
62
+ def cli():
63
+ """
64
+ esgpull is a management utility for files and datasets from ESGF.
65
+ """
66
+
67
+
68
+ for subcmd in SUBCOMMANDS:
69
+ cli.add_command(subcmd)
70
+
71
+
72
+ def main():
73
+ cli()
esgpull/cli/add.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from click.exceptions import Abort, Exit
7
+
8
+ from esgpull.cli.decorators import args, groups, opts
9
+ from esgpull.cli.utils import (
10
+ init_esgpull,
11
+ parse_query,
12
+ serialize_queries_from_file,
13
+ )
14
+ from esgpull.graph import Graph
15
+ from esgpull.models import Query
16
+ from esgpull.tui import Verbosity
17
+
18
+
19
+ @click.command()
20
+ @args.facets
21
+ @groups.query_def
22
+ @opts.query_file
23
+ @opts.track
24
+ @opts.record
25
+ @opts.verbosity
26
+ def add(
27
+ facets: list[str],
28
+ # query options
29
+ tags: list[str],
30
+ require: str | None,
31
+ distrib: str | None,
32
+ latest: str | None,
33
+ replica: str | None,
34
+ retracted: str | None,
35
+ # since: str | None,
36
+ query_file: Path | None,
37
+ track: bool,
38
+ record: bool,
39
+ verbosity: Verbosity,
40
+ ) -> None:
41
+ """
42
+ Add queries to the database
43
+
44
+ OPTIONS / FACETS examples:
45
+
46
+ esgpull add --distrib true variable_id:co2,co3 mip_era:CMIP6
47
+
48
+ Syntax reference: http://www.esgf.io/esgf-download/search/
49
+
50
+ esgpull add --query-file path/to/query.yaml
51
+
52
+ Valid query files are usually created with either `show --json/--yaml` or `convert` commands.
53
+
54
+ Queries are `untracked` by default.
55
+
56
+ To fetch files from ESGF and link them to a query, see the `track` and `update` commands.
57
+ """
58
+ esg = init_esgpull(verbosity, record=record)
59
+ with esg.ui.logging("add", onraise=Abort):
60
+ if query_file is not None:
61
+ queries = serialize_queries_from_file(query_file)
62
+ else:
63
+ query = parse_query(
64
+ facets=facets,
65
+ tags=tags,
66
+ require=require,
67
+ distrib=distrib,
68
+ latest=latest,
69
+ replica=replica,
70
+ retracted=retracted,
71
+ )
72
+ esg.graph.resolve_require(query)
73
+ if track:
74
+ if query.require is not None:
75
+ expanded = esg.graph.expand(query.require)
76
+ else:
77
+ expanded = query
78
+ query.track(expanded.options)
79
+ queries = [query]
80
+ subgraph = Graph(None)
81
+ subgraph.add(*queries)
82
+ esg.ui.print(subgraph)
83
+ empty = Query()
84
+ empty.compute_sha()
85
+ for query in queries:
86
+ query.compute_sha()
87
+ esg.graph.resolve_require(query)
88
+ if query.sha == empty.sha:
89
+ esg.ui.print(":stop_sign: Trying to add empty query.")
90
+ esg.ui.raise_maybe_record(Exit(1))
91
+ if query.sha in esg.graph: # esg.graph.has(sha=query.sha):
92
+ esg.ui.print(f"Skipping existing query: {query.rich_name}")
93
+ else:
94
+ esg.graph.add(query)
95
+ esg.ui.print(f"New query added: {query.rich_name}")
96
+ new_queries = esg.graph.merge()
97
+ nb = len(new_queries)
98
+ ies = "ies" if nb > 1 else "y"
99
+ if new_queries:
100
+ esg.ui.print(f":+1: {nb} new quer{ies} added.")
101
+ else:
102
+ esg.ui.print(":stop_sign: No new query was added.")
103
+ esg.ui.raise_maybe_record(Exit(0))
@@ -0,0 +1,38 @@
1
+ # import click
2
+ # from click.exceptions import Abort, Exit
3
+
4
+ # from esgpull import Esgpull
5
+ # from esgpull.cli.decorators import opts
6
+ # from esgpull.cli.utils import filter_docs, totable
7
+ # from esgpull.tui import Verbosity
8
+
9
+
10
+ # @click.command()
11
+ # @opts.force
12
+ # @opts.verbosity
13
+ # def autoremove(
14
+ # force: bool,
15
+ # verbosity: Verbosity,
16
+ # ):
17
+ # esg = Esgpull(verbosity=verbosity)
18
+ # with esg.ui.logging("autoremove", onraise=Abort):
19
+ # deprecated = esg.db.get_deprecated_files()
20
+ # nb = len(deprecated)
21
+ # if not nb:
22
+ # esg.ui.print("All files are up to date.")
23
+ # raise Exit(0)
24
+ # if not force:
25
+ # docs = filter_docs([file.raw for file in deprecated])
26
+ # esg.ui.print(totable(docs))
27
+ # s = "s" if nb > 1 else ""
28
+ # esg.ui.print(f"Found {nb} file{s} to remove.")
29
+ # if not esg.ui.ask("Continue?", default=True):
30
+ # raise Abort
31
+ # removed = esg.remove(*deprecated)
32
+ # esg.ui.print(f"Removed {len(removed)} files with newer version.")
33
+ # nb_remain = len(removed) - nb
34
+ # if nb_remain:
35
+ # esg.ui.print(f"{nb_remain} files could not be removed.")
36
+ # if force:
37
+ # docs = filter_docs([file.raw for file in removed])
38
+ # esg.ui.print(totable(docs))
esgpull/cli/config.py ADDED
@@ -0,0 +1,116 @@
1
+ from textwrap import dedent
2
+
3
+ import click
4
+ from click.exceptions import Abort, BadOptionUsage, Exit
5
+
6
+ from esgpull.cli.decorators import args, opts
7
+ from esgpull.cli.utils import init_esgpull
8
+ from esgpull.config import ConfigKind
9
+ from esgpull.tui import Verbosity
10
+
11
+
12
+ def extract_command(doc: dict, key: str | None) -> dict:
13
+ if key is None:
14
+ return doc
15
+ for part in key.split("."):
16
+ if not part:
17
+ raise KeyError(key)
18
+ elif part in doc:
19
+ doc = doc[part]
20
+ else:
21
+ raise KeyError(part)
22
+ for part in key.split(".")[::-1]:
23
+ doc = {part: doc}
24
+ return doc
25
+
26
+
27
+ @click.command()
28
+ @args.key
29
+ @args.value
30
+ @opts.default
31
+ @opts.generate
32
+ @opts.record
33
+ @opts.verbosity
34
+ def config(
35
+ key: str | None,
36
+ value: str | None,
37
+ default: bool,
38
+ generate: bool,
39
+ record: bool,
40
+ verbosity: Verbosity,
41
+ ):
42
+ """
43
+ View/modify config
44
+
45
+ The full config is shown when no arguments are provided. It includes all items overwritten
46
+ in the `config.toml` file, and default values otherwise.
47
+ Note that the `config.toml` file does not exist by default, an empty file will be created
48
+ on the first modification of any item. Otherwise one can generate a config file containing
49
+ every default value using the `--generate` flag.
50
+
51
+ To view a specific config section/item, the dot-separated path to that section/item must
52
+ be provided as the only argument.
53
+
54
+ To modify a config item, the dot-separated path to that item must be provided as the first
55
+ argument, along with the new value that item should get as the second argument.
56
+
57
+ Only config items can be modified.
58
+ """
59
+ esg = init_esgpull(verbosity=verbosity, load_db=False, record=record)
60
+ with esg.ui.logging("config", onraise=Abort):
61
+ if key is not None and value is not None:
62
+ if default:
63
+ raise BadOptionUsage(
64
+ "default",
65
+ dedent(
66
+ f"""
67
+ --default/-d is invalid with a value.
68
+ Instead use:
69
+
70
+ $ esgpull config {key} -d
71
+ """
72
+ ),
73
+ )
74
+ kind = esg.config.kind
75
+ old_value = esg.config.update_item(key, value, empty_ok=True)
76
+ info = extract_command(esg.config.dump(), key)
77
+ esg.config.write()
78
+ esg.ui.print(info, toml=True)
79
+ if kind == ConfigKind.NoFile:
80
+ esg.ui.print(
81
+ ":+1: New config file created at "
82
+ f"{esg.config._config_file}."
83
+ )
84
+ else:
85
+ esg.ui.print(f"Previous value: {old_value}")
86
+ elif key is not None:
87
+ if default:
88
+ old_value = esg.config.set_default(key)
89
+ info = extract_command(esg.config.dump(), key)
90
+ esg.config.write()
91
+ esg.ui.print(info, toml=True)
92
+ esg.ui.print(f"Previous value: {old_value}")
93
+ else:
94
+ info = extract_command(esg.config.dump(), key)
95
+ esg.ui.print(info, toml=True)
96
+ elif generate:
97
+ overwrite = False
98
+ if esg.config.kind == ConfigKind.Complete:
99
+ esg.ui.print(
100
+ f"{esg.config._config_file}\n"
101
+ ":+1: Your config file is already complete."
102
+ )
103
+ esg.ui.raise_maybe_record(Exit(0))
104
+ elif esg.config.kind == ConfigKind.Partial and esg.ui.ask(
105
+ "A config file already exists,"
106
+ " fill it with missing defaults?",
107
+ default=False,
108
+ ):
109
+ overwrite = True
110
+ esg.config.generate(overwrite=overwrite)
111
+ msg = f":+1: Config generated at {esg.config._config_file}"
112
+ esg.ui.print(msg)
113
+ else:
114
+ esg.ui.rule(str(esg.config._config_file))
115
+ esg.ui.print(esg.config.dump(), toml=True)
116
+ esg.ui.raise_maybe_record(Exit(0))