booleannet 2.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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: booleannet
3
+ Version: 2.0
4
+ Summary: Boolean network modeling
5
+ Author-email: Istvan Albert <istvan.albert@gmail.com>
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: click<9,>=8.5
@@ -0,0 +1,113 @@
1
+ # BooleanNet
2
+
3
+ BooleanNet is a training tool that makes use of existing Boolean network models, methods and algorithms.
4
+
5
+ ## Environment setup
6
+
7
+ `booleannet` does not automatically install all of its dependencies as there might be conflicts across dependencies. We recommend using [pixi][pixi] as a virtual environment manager.
8
+
9
+ An example using `pixi` to set up the enviroment is:
10
+
11
+ ```bash
12
+ pixi init
13
+ pixi shell
14
+ pixi add python=3.12 pip
15
+ pip install git+https://github.com/hklarner/pyboolnet@3.0.16
16
+ ```
17
+
18
+ Your enviroment is now set up with the necessary dependencies to use `booleannet`.
19
+
20
+ [pixi]: https://pixi.prefix.dev/latest/
21
+
22
+ ## Install booleannet
23
+
24
+ The libraires
25
+ ```bash
26
+ pip install booleannet
27
+ ```
28
+
29
+ It installs the `bnet` command line tool that implements a number of subcommands.
30
+
31
+
32
+ # bnet models: manage known models
33
+
34
+
35
+ ```bash
36
+ # List all models
37
+ bnet models | head
38
+ ```
39
+
40
+ prints models by increasing number of variables:
41
+
42
+ ```
43
+ id name var in reg
44
+ 165 EGGSHELL-PATTERNING-PHENOMOENOLOGICAL 4 4 16
45
+ 170 DROSOPHILA-GAP-B 4 3 15
46
+ 007 CORTICAL-AREA-DEVELOPMENT 5 0 14
47
+ 109 ASYMMETRIC-CELL-DIVISION-A 5 0 15
48
+ 169 DROSOPHILA-GAP-A 5 2 17
49
+ 171 DROSOPHILA-GAP-C 5 2 20
50
+ 172 DROSOPHILA-GAP-D 5 2 12
51
+ 184 P53-MDM2-NETWORK 5 1 15
52
+ 189 TRP-BIOSYNTHESIS 5 1 13
53
+ ...
54
+ ```
55
+
56
+ To get the rules for a specific model:
57
+
58
+ ```bash
59
+ # Get rules for model 7
60
+ bnet models 7
61
+ ```
62
+
63
+ prints:
64
+
65
+ ```
66
+ Coup_fti* = not (Fgf8 or Sp8) or not (Sp8 or Fgf8)
67
+ Emx2* = Coup_fti and not (Fgf8 or Sp8 or Pax6)
68
+ Fgf8* = Fgf8 and Sp8 and not Emx2
69
+ Pax6* = Sp8 and not (Emx2 or Coup_fti)
70
+ Sp8* = Fgf8 and not Emx2
71
+ ```
72
+
73
+ You can also get the rules for a model by name:
74
+
75
+ ```bash
76
+ # Get rules for model by name
77
+ bnet models CORTICAL-AREA-DEVELOPMENT
78
+ ```
79
+
80
+ Get the rules in other formats:
81
+
82
+ ```bash
83
+ # Get model 7 in BNet format
84
+ bnet models 7 -f bnet
85
+ ```
86
+
87
+ ## bnet graphviz: visualize a model
88
+
89
+ ```bash
90
+ # If you have a model in a file
91
+ bnet graphviz -i model.txt
92
+ ```
93
+
94
+ ```bash
95
+ # You can pipe the rule to graphviz
96
+ bnet models CORTICAL-AREA-DEVELOPMENT | bnet graphviz
97
+ ```
98
+
99
+ ## Convert BBMB to JSON
100
+
101
+ This is used internally to transform the BBMB model repository to a single JSON file.
102
+
103
+ Skips a few large models that make the file too large.
104
+
105
+ ```bash
106
+ python booleannet/bbm2json.py \
107
+ --summary ~/src/biodivine-boolean-models/models/summary.csv
108
+ --models ~/src/biodivine-boolean-models/models
109
+ --skip 253,248,79,261,256
110
+ --output models.json.gz
111
+ ```
112
+
113
+ ## Convert rules to images
@@ -0,0 +1 @@
1
+ """Boolean network modeling."""
@@ -0,0 +1,3 @@
1
+ from booleannet.cli import main
2
+
3
+ main()
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Convert BBM models files to a JSON data file.
4
+
5
+ Join models/summary.csv to each model directory and write one JSON object.
6
+
7
+ Directory names follow the summary columns (regulations is not in the name):
8
+
9
+ \b
10
+ [id-009]__[var-60]__[in-13]__[YEAST-APOPTOSIS]
11
+
12
+ The output is a JSON object keyed by the zero-padded id from the CSV:
13
+
14
+ \b
15
+ {
16
+ "009": {
17
+ "summary": {
18
+ "name": "YEAST-APOPTOSIS",
19
+ "variables": 60,
20
+ "inputs": 13,
21
+ "regulations": 114
22
+ },
23
+ "metadata": { ... }, # metadata.json, parsed
24
+ "readme": "...", # README.md
25
+ "bnet": "...", # model.bnet
26
+ "aeon": "...", # model.aeon
27
+ "sbml": "...", # model.sbml
28
+ "bma": { ... }, # model.bma.json, parsed
29
+ "booleannet": "...", # model.booleannet.txt
30
+ "inferred_graph": "..." # model.inferred-graph.aeon
31
+ }
32
+ }
33
+
34
+ JSON files are stored as objects. The other formats stay as text.
35
+ """
36
+
37
+ import csv
38
+ import gzip
39
+ import json
40
+ import re
41
+ from pathlib import Path
42
+
43
+ import click
44
+
45
+ ROOT = Path(__file__).resolve().parent
46
+
47
+ DIR_RE = re.compile(
48
+ r"^\[id-(?P<id>\d+)\]__\[var-(?P<variables>\d+)\]__\[in-(?P<inputs>\d+)\]__\[(?P<name>.+)\]$"
49
+ )
50
+
51
+ # filename -> (output field, "json" or "text")
52
+ FILES = {
53
+ "metadata.json": ("metadata", "json"),
54
+ "README.md": ("readme", "text"),
55
+ "model.bnet": ("bnet", "text"),
56
+ "model.aeon": ("aeon", "text"),
57
+ "model.sbml": ("sbml", "text"),
58
+ "model.bma.json": ("bma", "json"),
59
+ "model.booleannet.txt": ("booleannet", "text"),
60
+ "model.inferred-graph.aeon": ("inferred_graph", "text"),
61
+ }
62
+
63
+
64
+ def read_summary(path):
65
+ with path.open(newline="") as f:
66
+ rows = list(csv.DictReader(f, skipinitialspace=True))
67
+ by_id = {}
68
+ for row in rows:
69
+ model_id = row["ID"].zfill(3)
70
+ if model_id in by_id:
71
+ raise SystemExit(f"duplicate id {model_id} in {path}")
72
+ by_id[model_id] = {
73
+ "name": row["name"],
74
+ "variables": int(row["variables"]),
75
+ "inputs": int(row["inputs"]),
76
+ "regulations": int(row["regulations"]),
77
+ }
78
+ return by_id
79
+
80
+
81
+ def index_dirs(models_dir):
82
+ by_id = {}
83
+ for path in sorted(models_dir.iterdir()):
84
+ if not path.is_dir():
85
+ continue
86
+ match = DIR_RE.match(path.name)
87
+ if not match:
88
+ raise SystemExit(f"directory name does not match summary pattern: {path.name}")
89
+ model_id = match.group("id").zfill(3)
90
+ if model_id in by_id:
91
+ raise SystemExit(f"duplicate id {model_id}: {path.name}")
92
+ by_id[model_id] = (path, match.groupdict())
93
+ return by_id
94
+
95
+
96
+ def load_files(directory):
97
+ data = {}
98
+ for filename, (field, kind) in FILES.items():
99
+ path = directory / filename
100
+ if not path.is_file():
101
+ raise SystemExit(f"missing {path}")
102
+ text = path.read_text()
103
+ data[field] = json.loads(text) if kind == "json" else text
104
+ return data
105
+
106
+
107
+ def parse_skip(ctx, param, value):
108
+ ids = set()
109
+ for part in value.split(","):
110
+ part = part.strip()
111
+ if not part:
112
+ continue
113
+ if not part.isdigit():
114
+ raise click.BadParameter(f"not a model id: {part}")
115
+ ids.add(part.zfill(3))
116
+ return ids
117
+
118
+
119
+ def drop_skipped(summary, dirs, skip):
120
+ unknown = sorted(skip - set(summary) - set(dirs))
121
+ if unknown:
122
+ raise SystemExit(f"unknown skip id: {', '.join(unknown)}")
123
+ for model_id in skip:
124
+ summary.pop(model_id, None)
125
+ dirs.pop(model_id, None)
126
+
127
+
128
+ def build(summary, dirs):
129
+ missing = sorted(set(summary) - set(dirs))
130
+ extra = sorted(set(dirs) - set(summary))
131
+ if missing or extra:
132
+ click.echo(
133
+ f"warning: summary/directory mismatch missing={missing} extra={extra}",
134
+ err=True,
135
+ )
136
+ for model_id in missing:
137
+ del summary[model_id]
138
+ for model_id in extra:
139
+ del dirs[model_id]
140
+
141
+ models = {}
142
+ for model_id in sorted(summary):
143
+ row = summary[model_id]
144
+ print(f"Processing: {row}")
145
+ path, parsed = dirs[model_id]
146
+ parsed_vars = int(parsed["variables"])
147
+ parsed_inputs = int(parsed["inputs"])
148
+ if (
149
+ parsed["name"] != row["name"]
150
+ or parsed_vars != row["variables"]
151
+ or parsed_inputs != row["inputs"]
152
+ ):
153
+ raise SystemExit(
154
+ f"id {model_id} summary {row} does not match directory {path.name}"
155
+ )
156
+ models[model_id] = {
157
+ "summary": row,
158
+ **load_files(path),
159
+ }
160
+ return models
161
+
162
+
163
+ @click.command(help=__doc__)
164
+ @click.option(
165
+ "--summary",
166
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
167
+ default= Path("models") / "summary.csv",
168
+ show_default=True,
169
+ help="Summary CSV joined onto each model.",
170
+ )
171
+ @click.option(
172
+ "--models",
173
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
174
+ default=Path("models"),
175
+ show_default=True,
176
+ help="Directory of model folders.",
177
+ )
178
+ @click.option(
179
+ "--skip",
180
+ default="",
181
+ show_default=True,
182
+ callback=parse_skip,
183
+ help="Comma-separated model ids to leave out of the JSON. Ids are zero-padded to 3 digits.",
184
+ )
185
+ @click.option(
186
+ "-o",
187
+ "--output",
188
+ type=click.Path(dir_okay=False, path_type=Path),
189
+ default="models.json.gz",
190
+ show_default=True,
191
+ help="JSON file to write.",
192
+ )
193
+ def main(summary: Path, models: Path, output: Path, skip: set[str]) -> None:
194
+ rows = read_summary(summary)
195
+ dirs = index_dirs(models)
196
+ drop_skipped(rows, dirs, skip)
197
+ data = build(rows, dirs)
198
+ stream = gzip.open(output, "wt") if output.suffix == ".gz" else output.open("wt")
199
+ with stream as f:
200
+ json.dump(data, f, ensure_ascii=False, separators=(",", ":"), indent=2)
201
+ f.write("\n")
202
+ note = f", skipped {len(skip)}" if skip else ""
203
+ click.echo(f"wrote {len(data)} models to {output}{note}", err=True)
204
+
205
+
206
+ if __name__ == "__main__":
207
+ main()
@@ -0,0 +1,27 @@
1
+ """bnet command line."""
2
+
3
+ import click
4
+
5
+ from booleannet.commands.gviz import cli as gviz
6
+ from booleannet.commands.models import main as models
7
+
8
+ try:
9
+ import pyboolnet
10
+ except ImportError:
11
+ print("# Error: pyboolnet is not installed. See the docs`.")
12
+ exit(1)
13
+
14
+ # Subcommand name -> click command. Add a new tool here.
15
+ COMMANDS = {
16
+ "models": models,
17
+ "graphviz": gviz,
18
+ }
19
+
20
+
21
+ @click.group("bnet", no_args_is_help=True)
22
+ def main() -> None:
23
+ """BooleanNet command line tools."""
24
+
25
+
26
+ for name, cmd in COMMANDS.items():
27
+ main.add_command(cmd, name=name)
@@ -0,0 +1 @@
1
+ """Subcommands for ``bnet``. Register each one in ``booleannet.cli.COMMANDS``."""
@@ -0,0 +1,107 @@
1
+ """Build a Graphviz interaction graph from BooleanNet rules."""
2
+
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from pyboolnet import log
10
+
11
+ import click
12
+ from pyboolnet.boolean_normal_forms import functions2primes
13
+ from pyboolnet.interaction_graphs import (
14
+ add_style_interactionsigns,
15
+ igraph2dot,
16
+ primes2igraph,
17
+ )
18
+
19
+ KEYWORDS = {"and", "or", "not", "True", "False"}
20
+ ENGINES = ("dot", "neato", "fdp", "sfdp", "circo", "twopi")
21
+ IDENT = re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*\b")
22
+
23
+
24
+ def booleannet2functions(text: str) -> dict:
25
+ """Turn BooleanNet update rules into callables for pyboolnet.
26
+
27
+ Parameter names are sorted because functions2primes calls each
28
+ function with arguments in alphabetical order.
29
+ """
30
+ funcs = {}
31
+ for raw in text.splitlines():
32
+ line = raw.split("#", 1)[0].strip()
33
+ if not line:
34
+ continue
35
+ lhs, rhs = line.split("=", 1)
36
+ name = lhs.strip().removesuffix("*").strip()
37
+ expr = rhs.strip()
38
+ args = sorted(set(IDENT.findall(expr)) - KEYWORDS)
39
+ params = ", ".join(args)
40
+ src = f"lambda {params}: {expr}" if args else f"lambda: {expr}"
41
+ funcs[name] = eval(src, {"True": True, "False": False})
42
+ return funcs
43
+
44
+
45
+ def read_rules(path: Path | None) -> tuple[str, Path | None]:
46
+ """Return rules text and the file output names inherit from.
47
+
48
+ A pipe is the rules source when stdin is not a terminal. Otherwise the
49
+ source is ``path`` or ``input.txt``. The returned path is that file, or
50
+ ``None`` when the rules came from stdin and no input file was given.
51
+ """
52
+ if not sys.stdin.isatty():
53
+ return sys.stdin.read(), path
54
+ path = path or Path("input.txt")
55
+ if not path.is_file():
56
+ raise click.ClickException(f"rules file not found: {path}")
57
+ return path.read_text(), path
58
+
59
+
60
+ def rules2dot(text: str, dot_path: Path) -> None:
61
+ primes = functions2primes(booleannet2functions(text))
62
+ graph = primes2igraph(primes)
63
+ # Default width is ~0.2in for one-letter names, which clips the labels.
64
+ graph.graph["node"]["width"] = "0.55"
65
+ graph.graph["node"]["fontsize"] = "14"
66
+ graph.graph["node"]["color"] = "gray20"
67
+ graph.graph["node"]["penwidth"] = "1.4"
68
+ # width is a minimum; let longer labels expand the circle
69
+ graph.graph["node"]["fixedsize"] = "false"
70
+ for name in graph.nodes:
71
+ if name.startswith("v_"):
72
+ graph.nodes[name]["label"] = name.removeprefix("v_")
73
+ add_style_interactionsigns(graph)
74
+ igraph2dot(graph, str(dot_path))
75
+
76
+
77
+ def write_image(dot: Path, image: Path, engine: str = "neato") -> None:
78
+ """Render a dot file. The image format is the output suffix, such as png or pdf.
79
+
80
+ pyboolnet's layout lookup is hardcoded to /usr/bin, so this calls the engine on PATH.
81
+ """
82
+ exe = shutil.which(engine)
83
+ if exe is None:
84
+ raise click.ClickException(f"{engine} not found on PATH")
85
+ fmt = image.suffix.lstrip(".").lower()
86
+ if not fmt:
87
+ raise click.ClickException(f"image path needs an extension such as .png or .pdf: {image}")
88
+ subprocess.run([exe, f"-T{fmt}", str(dot), "-o", str(image)], check=True)
89
+ log.info(f"image written to {image}")
90
+
91
+
92
+ @click.command()
93
+ @click.option("-i", "--input", "rules", type=click.Path(dir_okay=False, path_type=Path), help="BooleanNet rules file. Used when stdin is a terminal. Default: input.txt.")
94
+ @click.option("-d", "--dot", "dot", type=click.Path(dir_okay=False, path_type=Path), help="Dot output. Default: input path with a .dot suffix, or output.dot when reading stdin with no input file.")
95
+ @click.option("-o", "--output", "image", type=click.Path(dir_okay=False, path_type=Path), help="Image output. Default: input path with a .pdf suffix, or output.pdf when reading stdin with no input file. Format follows the extension (png, pdf, svg).")
96
+ @click.option("-e", "--engine", default="circo", show_default=True, type=click.Choice(ENGINES), help="Graphviz layout engine.")
97
+ def cli(rules: Path | None, dot: Path | None, image: Path | None, engine: str) -> None:
98
+ """Generates a Graphviz graph from a model."""
99
+ text, source = read_rules(rules)
100
+ if source is None:
101
+ dot = dot or Path("output.dot")
102
+ image = image or Path("output.pdf")
103
+ else:
104
+ dot = dot or source.with_suffix(".dot")
105
+ image = image or source.with_suffix(".pdf")
106
+ rules2dot(text, dot)
107
+ write_image(dot, image, engine)
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env python3
2
+ """List models or print one model in a chosen format.
3
+
4
+ bnet models one summary row per model
5
+ bnet models 1 bnet for model 001
6
+ bnet models CORTICAL model whose name contains CORTICAL
7
+ bnet models 1 -f aeon aeon for model 001
8
+ bnet models -d models.json read a chosen JSON database
9
+ """
10
+
11
+ import gzip
12
+ import json
13
+ import sys
14
+ from importlib.resources import files
15
+ from pathlib import Path
16
+
17
+ import click
18
+
19
+ DATA = files("booleannet").joinpath("data", "models.json.gz")
20
+ FORMATS = (
21
+ "bnet",
22
+ "booleannet",
23
+ "sbml",
24
+ "aeon",
25
+ "bma",
26
+ "inferred_graph",
27
+ "metadata",
28
+ "readme",
29
+ )
30
+ JSON_FORMATS = {"bma", "metadata"}
31
+
32
+
33
+ def load_models(path: Path | None):
34
+ if path is None:
35
+ with DATA.open("rb") as raw, gzip.open(raw, "rt") as f:
36
+ return json.load(f)
37
+ opener = gzip.open if path.suffix == ".gz" else open
38
+ with opener(path, "rt") as f:
39
+ return json.load(f)
40
+
41
+
42
+ def list_summaries(models):
43
+ rows = []
44
+ for model_id, model in models.items():
45
+ summary = model["summary"]
46
+ rows.append((
47
+ model_id,
48
+ summary["name"],
49
+ summary["variables"],
50
+ summary["inputs"],
51
+ summary["regulations"],
52
+ ))
53
+ rows.sort(key=lambda row: (row[2], row[0]))
54
+ name_w = max(len(name) for _, name, _, _, _ in rows)
55
+ click.echo(f"{'id':3} {'name':<{name_w}} {'var':>5} {'in':>4} {'reg':>5}")
56
+ for model_id, name, variables, inputs, regulations in rows:
57
+ click.echo(f"{model_id} {name:<{name_w}} {variables:5} {inputs:4} {regulations:5}")
58
+
59
+
60
+ def find_models(models, key: str) -> list[str]:
61
+ """Match a number to an id, or text to an id or name."""
62
+ if key.isdigit():
63
+ model_id = key.zfill(3)
64
+ return [model_id] if model_id in models else []
65
+ needle = key.casefold()
66
+ return [
67
+ model_id
68
+ for model_id, model in models.items()
69
+ if needle in model_id.casefold() or needle in model["summary"]["name"].casefold()
70
+ ]
71
+
72
+
73
+ def emit(model, fmt):
74
+ value = model[fmt]
75
+ if fmt in JSON_FORMATS:
76
+ click.echo(json.dumps(value, ensure_ascii=False))
77
+ return
78
+ click.echo(value, nl=not value.endswith("\n"))
79
+
80
+
81
+ @click.command()
82
+ @click.argument("key", required=False)
83
+ @click.option(
84
+ "-d",
85
+ "--database",
86
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
87
+ help="JSON or JSON.gz database. Default: packaged data/models.json.gz.",
88
+ )
89
+ @click.option(
90
+ "-f",
91
+ "--format",
92
+ "fmt",
93
+ default="booleannet",
94
+ show_default=True,
95
+ type=click.Choice(FORMATS),
96
+ help="Model file to print when KEY is given.",
97
+ )
98
+ def main(key, database, fmt):
99
+ """List model summaries, or print one model in the chosen format.
100
+
101
+ KEY is a number (1 or 001) or text matched against the id or name.
102
+ """
103
+ models = load_models(database)
104
+ if key is None:
105
+ list_summaries(models)
106
+ return
107
+
108
+ hits = find_models(models, key)
109
+ if not hits:
110
+ raise click.ClickException(f"no model {key}")
111
+ if len(hits) > 1:
112
+ lines = [f"{i} {models[i]['summary']['name']}" for i in sorted(hits)]
113
+ raise click.ClickException("several models match:\n" + "\n".join(lines))
114
+ emit(models[hits[0]], fmt)
115
+
116
+
117
+ if __name__ == "__main__":
118
+ try:
119
+ main()
120
+ except BrokenPipeError:
121
+ sys.stdout.close()
122
+ sys.exit(0)
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: booleannet
3
+ Version: 2.0
4
+ Summary: Boolean network modeling
5
+ Author-email: Istvan Albert <istvan.albert@gmail.com>
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: click<9,>=8.5
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ booleannet/__init__.py
4
+ booleannet/__main__.py
5
+ booleannet/bbm2json.py
6
+ booleannet/cli.py
7
+ booleannet.egg-info/PKG-INFO
8
+ booleannet.egg-info/SOURCES.txt
9
+ booleannet.egg-info/dependency_links.txt
10
+ booleannet.egg-info/entry_points.txt
11
+ booleannet.egg-info/requires.txt
12
+ booleannet.egg-info/top_level.txt
13
+ booleannet/commands/__init__.py
14
+ booleannet/commands/gviz.py
15
+ booleannet/commands/models.py
16
+ booleannet/data/models.json.gz
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bnet = booleannet.cli:main
@@ -0,0 +1 @@
1
+ click<9,>=8.5
@@ -0,0 +1 @@
1
+ booleannet
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "booleannet"
7
+ version = "2.0"
8
+ description = "Boolean network modeling"
9
+ authors = [{ name = "Istvan Albert", email = "istvan.albert@gmail.com" }]
10
+ requires-python = ">=3.12"
11
+ dependencies = ["click>=8.5,<9"]
12
+
13
+ [project.scripts]
14
+ bnet = "booleannet.cli:main"
15
+
16
+ [tool.setuptools.packages.find]
17
+ include = ["booleannet*"]
18
+
19
+ [tool.setuptools.package-data]
20
+ booleannet = ["data/*.json.gz"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+