dartboard-ia 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ Creative Commons Legal Code
2
+
3
+ CC0 1.0 Universal
4
+
5
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
6
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
7
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
8
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
9
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
10
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
11
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
12
+ HEREUNDER.
13
+
14
+ Statement of Purpose
15
+
16
+ The laws of most jurisdictions throughout the world automatically confer
17
+ exclusive Copyright and Related Rights (defined below) upon the creator
18
+ and subsequent owner(s) (each and all, an "owner") of an original work of
19
+ authorship and/or a database (each, a "Work").
20
+
21
+ Certain owners wish to permanently relinquish those rights to a Work for
22
+ the purpose of contributing to a commons of creative, cultural and
23
+ scientific works ("Commons") that the public can reliably and without fear
24
+ of later claims of infringement build upon, modify, incorporate in other
25
+ works, reuse and redistribute as freely as possible in any form whatsoever
26
+ and for any purposes, including without limitation commercial purposes.
27
+ These owners may contribute to the Commons to promote the ideal of a free
28
+ culture and the further production of creative, cultural and scientific
29
+ works, or to gain reputation or greater distribution for their Work in
30
+ part through the use and efforts of others.
31
+
32
+ For these and/or other purposes and motivations, and without any
33
+ expectation of additional consideration or compensation, the person
34
+ associating CC0 with a Work (the "Affirmer"), to the extent that he or she
35
+ is an owner of Copyright and Related Rights in the Work, voluntarily
36
+ elects to apply CC0 to the Work and publicly distribute the Work under its
37
+ terms, with knowledge of his or her Copyright and Related Rights in the
38
+ Work and the meaning and intended legal effect of CC0 on those rights.
39
+
40
+ 1. Copyright and Related Rights. A Work made available under CC0 may be
41
+ protected by copyright and related or neighboring rights ("Copyright and
42
+ Related Rights"). Copyright and Related Rights include, but are not
43
+ limited to, the following:
44
+
45
+ i. the right to reproduce, adapt, distribute, perform, display,
46
+ communicate, and translate a Work;
47
+ ii. moral rights retained by the original author(s) and/or performer(s);
48
+ iii. publicity and privacy rights pertaining to a person's image or
49
+ likeness depicted in a Work;
50
+ iv. rights protecting against unfair competition in regards to a Work,
51
+ subject to the limitations in paragraph 4(a), below;
52
+ v. rights protecting the extraction, dissemination, use and reuse of data
53
+ in a Work;
54
+ vi. database rights (such as those arising under Directive 96/9/EC of the
55
+ European Parliament and of the Council of 11 March 1996 on the legal
56
+ protection of databases, and under any national implementation
57
+ thereof, including any amended or successor version of such
58
+ directive); and
59
+ vii. other similar, equivalent or corresponding rights throughout the
60
+ world based on applicable law or treaty, and any national
61
+ implementations thereof.
62
+
63
+ 2. Waiver. To the greatest extent permitted by, but not in contravention
64
+ of, applicable law, Affirmer hereby overtly, fully, permanently,
65
+ irrevocably and unconditionally waives, abandons, and surrenders all of
66
+ Affirmer's Copyright and Related Rights and associated claims and causes
67
+ of action, whether now known or unknown (including existing as well as
68
+ future claims and causes of action), in the Work (i) in all territories
69
+ worldwide, (ii) for the maximum duration provided by applicable law or
70
+ treaty (including future time extensions), (iii) in any current or future
71
+ medium and for any number of copies, and (iv) for any purpose whatsoever,
72
+ including without limitation commercial, advertising or promotional
73
+ purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
74
+ member of the public at large and to the detriment of Affirmer's heirs and
75
+ successors, fully intending that such Waiver shall not be subject to
76
+ revocation, rescission, cancellation, termination, or any other legal or
77
+ equitable action to disrupt the quiet enjoyment of the Work by the public
78
+ as contemplated by Affirmer's express Statement of Purpose.
79
+
80
+ 3. Public License Fallback. Should any part of the Waiver for any reason
81
+ be judged legally invalid or ineffective under applicable law, then the
82
+ Waiver shall be preserved to the maximum extent permitted taking into
83
+ account Affirmer's express Statement of Purpose. In addition, to the
84
+ extent the Waiver is so judged Affirmer hereby grants to each affected
85
+ person a royalty-free, non transferable, non sublicensable, non exclusive,
86
+ irrevocable and unconditional license to exercise Affirmer's Copyright and
87
+ Related Rights in the Work (i) in all territories worldwide, (ii) for the
88
+ maximum duration provided by applicable law or treaty (including future
89
+ time extensions), (iii) in any current or future medium and for any number
90
+ of copies, and (iv) for any purpose whatsoever, including without
91
+ limitation commercial, advertising or promotional purposes (the
92
+ "License"). The License shall be deemed effective as of the date CC0 was
93
+ applied by Affirmer to the Work. Should any part of the License for any
94
+ reason be judged legally invalid or ineffective under applicable law, such
95
+ partial invalidity or ineffectiveness shall not invalidate the remainder
96
+ of the License, and in such case Affirmer hereby affirms that he or she
97
+ will not (i) exercise any of his or her remaining Copyright and Related
98
+ Rights in the Work or (ii) assert any associated claims and causes of
99
+ action with respect to the Work, in either case contrary to Affirmer's
100
+ express Statement of Purpose.
101
+
102
+ 4. Limitations and Disclaimers.
103
+
104
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
105
+ surrendered, licensed or otherwise affected by this document.
106
+ b. Affirmer offers the Work as-is and makes no representations or
107
+ warranties of any kind concerning the Work, express, implied,
108
+ statutory or otherwise, including without limitation warranties of
109
+ title, merchantability, fitness for a particular purpose, non
110
+ infringement, or the absence of latent or other defects, accuracy, or
111
+ the present or absence of errors, whether or not discoverable, all to
112
+ the greatest extent permissible under applicable law.
113
+ c. Affirmer disclaims responsibility for clearing rights of other persons
114
+ that may apply to the Work or any use thereof, including without
115
+ limitation any person's Copyright and Related Rights in the Work.
116
+ Further, Affirmer disclaims responsibility for obtaining any necessary
117
+ consents, permissions or other rights required for any use of the
118
+ Work.
119
+ d. Affirmer understands and acknowledges that Creative Commons is not a
120
+ party to this document and has no duty or obligation with respect to
121
+ this CC0 or use of the Work.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: dartboard-ia
3
+ Version: 0.2.1
4
+ Summary: Internet Archive upload management tool
5
+ License-File: LICENSE
6
+ Author: DigitalDragon
7
+ Requires-Python: >=3.8
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.8
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Dist: dataclasses-json
17
+ Requires-Dist: internetarchive (>=5.0.3)
18
+ Project-URL: Repository, https://github.com/DigitalDwagon/dartboard
19
+ Description-Content-Type: text/markdown
20
+
21
+ # dartboard
22
+ generalized Internet Archive upload target
23
+
24
+ ## Configuration
25
+ To use dartboard, you'll need a `config.json` in the same directory where you run it (or pass the location of it with `--config-path` in your dartboard command).
26
+ ```json
27
+ {
28
+ "s3_key": "abcdefg",
29
+ "s3_secret": "abcdefg"
30
+ }
31
+ ```
32
+
33
+ ## Usage instructions
34
+ You can upload a directory with
35
+ ```bash
36
+ dartboard path/to/directory
37
+ ```
38
+ dartboard will try to upload to the item with the same identifier as the name of the directory. If the item does not exist, you must specify the metadata by placing an `__ia_meta.json` file in the directory:
39
+ ```json
40
+ {
41
+ "collection": "opensource",
42
+ "mediatype": "data",
43
+ "title": "My title",
44
+ "description": "My description",
45
+ "foo": "bar"
46
+ }
47
+ ```
48
+ If the item already exists, dartboard will try to upload the files to it with or without `__ia_meta.json` being present. If it is present, dartboard WILL attempt to diff the metadata and make the necessary changes once it has finished uploading the new files.
49
+
50
+ You can also specify dartboard settings for that item with an `__uploader_meta.json` file:
51
+ ```java
52
+ {
53
+ "setUploadState": false, // if enabled, dartboard will set an upload-state:uploading key on the item, and change it to upload-state:uploaded when done
54
+ "setScanner": true, // if disabled, dartboard will not add "dartboard (vX.Y.Z)" to the scanner field
55
+ "sendSizeHint": false, // if enabled, dartboard will send IA a size hint for the item, based on the size of files in the directory
56
+ "derive": true // if disabled, dartboard will not queue a derive task once it has finished uploading
57
+ }
58
+ ```
59
+
@@ -0,0 +1,38 @@
1
+ # dartboard
2
+ generalized Internet Archive upload target
3
+
4
+ ## Configuration
5
+ To use dartboard, you'll need a `config.json` in the same directory where you run it (or pass the location of it with `--config-path` in your dartboard command).
6
+ ```json
7
+ {
8
+ "s3_key": "abcdefg",
9
+ "s3_secret": "abcdefg"
10
+ }
11
+ ```
12
+
13
+ ## Usage instructions
14
+ You can upload a directory with
15
+ ```bash
16
+ dartboard path/to/directory
17
+ ```
18
+ dartboard will try to upload to the item with the same identifier as the name of the directory. If the item does not exist, you must specify the metadata by placing an `__ia_meta.json` file in the directory:
19
+ ```json
20
+ {
21
+ "collection": "opensource",
22
+ "mediatype": "data",
23
+ "title": "My title",
24
+ "description": "My description",
25
+ "foo": "bar"
26
+ }
27
+ ```
28
+ If the item already exists, dartboard will try to upload the files to it with or without `__ia_meta.json` being present. If it is present, dartboard WILL attempt to diff the metadata and make the necessary changes once it has finished uploading the new files.
29
+
30
+ You can also specify dartboard settings for that item with an `__uploader_meta.json` file:
31
+ ```java
32
+ {
33
+ "setUploadState": false, // if enabled, dartboard will set an upload-state:uploading key on the item, and change it to upload-state:uploaded when done
34
+ "setScanner": true, // if disabled, dartboard will not add "dartboard (vX.Y.Z)" to the scanner field
35
+ "sendSizeHint": false, // if enabled, dartboard will send IA a size hint for the item, based on the size of files in the directory
36
+ "derive": true // if disabled, dartboard will not queue a derive task once it has finished uploading
37
+ }
38
+ ```
File without changes
@@ -0,0 +1,3 @@
1
+ import importlib.metadata
2
+
3
+ version : str = importlib.metadata.version("dartboard-ia")
@@ -0,0 +1,12 @@
1
+ import dataclasses
2
+
3
+ @dataclasses.dataclass
4
+ class Config:
5
+ s3_key: str = ""
6
+ s3_secret: str = ""
7
+
8
+ staging_directory: str = "dartboard-staging"
9
+ working_directory: str = "dartboard-uploading"
10
+ done_directory: str = "dartboard-done"
11
+
12
+ dry_run: bool = False
@@ -0,0 +1,13 @@
1
+ import dataclasses
2
+
3
+ import dataclasses_json
4
+ from dataclasses_json import LetterCase, dataclass_json
5
+
6
+
7
+ @dataclass_json(letter_case=LetterCase.CAMEL)
8
+ @dataclasses.dataclass
9
+ class UploaderMeta:
10
+ set_scanner : bool = True
11
+ set_upload_state: bool = False
12
+ send_size_hint: bool = False
13
+ derive: bool = True
@@ -0,0 +1,40 @@
1
+ import argparse
2
+ import json
3
+ import logging
4
+
5
+ from dartboard.__version__ import version
6
+ from dartboard.config import Config
7
+ from dartboard.upload import upload
8
+
9
+ def main():
10
+ logging.basicConfig(level=logging.INFO,
11
+ format='%(asctime)s | %(message)s',
12
+ datefmt='%Y-%m-%d %H:%M:%S')
13
+ logging.log(logging.INFO, "Dartboard is loading...")
14
+
15
+
16
+ parser = argparse.ArgumentParser(description=f"dartboard (v{version})")
17
+ parser.add_argument("--config-path", dest="config_path", type=str, default="config.json",
18
+ help="Path to the config file (default: ./config.json)")
19
+ parser.add_argument("path", type=str, nargs="?", default=None,
20
+ help="Path to the directory to upload")
21
+ parser.add_argument("--dry-run", dest="dry_run", action="store_true",
22
+ help="Run the upload process without actually uploading anything")
23
+ args = parser.parse_args()
24
+
25
+ if args.path is None:
26
+ parser.print_help()
27
+ exit(1)
28
+
29
+ with open(args.config_path, "r") as config_file:
30
+ config_dict = json.loads(config_file.read().replace("\n", ""))
31
+
32
+ config = Config(**config_dict)
33
+ if args.dry_run:
34
+ config.dry_run = True
35
+
36
+ upload(config, args.path)
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
@@ -0,0 +1,236 @@
1
+ import hashlib
2
+ import json
3
+ import logging
4
+ import os.path
5
+ import re
6
+ import time
7
+
8
+ import internetarchive
9
+ from internetarchive import get_tasks, get_session, ArchiveSession
10
+
11
+ from dartboard.config import Config
12
+ from dartboard.items import UploaderMeta
13
+ from dartboard.__version__ import version
14
+
15
+
16
+ def upload(config: Config, path: str):
17
+ path = os.path.normpath(path)
18
+ logging.log(logging.INFO, f"Uploading {path}...")
19
+
20
+ identifier: str = get_identifier(path)
21
+ if not identifier:
22
+ return False
23
+ logging.log(logging.INFO, f"Identifier: {identifier} - this item will try to upload to https://archive.org/details/{identifier}")
24
+
25
+ session: ArchiveSession = get_session({"s3": {"access": config.s3_key, "secret": config.s3_secret}})
26
+ item = internetarchive.get_item(identifier, archive_session=session)
27
+ metadata = load_metadata(path)
28
+ settings = load_uploader_settings(path)
29
+
30
+ if not metadata and not item.exists:
31
+ logging.log(logging.ERROR, f"{identifier} does not exist and doesn't have a metadata file. Cannot upload.")
32
+ return False
33
+
34
+ if settings.set_scanner:
35
+ if "scanner" not in metadata or not metadata["scanner"]:
36
+ metadata["scanner"] = []
37
+ if isinstance(metadata["scanner"], str):
38
+ metadata["scanner"] = [metadata["scanner"]]
39
+ metadata["scanner"].append(f"dartboard (v{version})")
40
+
41
+ if settings.set_upload_state:
42
+ metadata["upload-state"] = "uploading"
43
+
44
+ logging.log(logging.INFO, f"Metadata: {json.dumps(metadata, indent=4)}")
45
+
46
+ files_to_upload: dict[str, str] = get_files_to_upload(path, item)
47
+
48
+ logging.log(logging.INFO, f"Found files:")
49
+ for filepath, destination in files_to_upload.items():
50
+ logging.log(logging.INFO, f" {filepath} -> {destination}")
51
+
52
+ if not files_to_upload.items():
53
+ logging.log(logging.INFO, f"No files to upload!")
54
+ return True
55
+
56
+ uploaded_files: dict[str, str] = {}
57
+
58
+ for filepath, destination in files_to_upload.items():
59
+ headers = {}
60
+
61
+ if settings.send_size_hint and not uploaded_files:
62
+ headers["x-archive-size-hint"] = str(get_size(files_to_upload))
63
+
64
+ logging.log(logging.INFO, f"Uploading {filepath} to {identifier}...")
65
+ if config.dry_run:
66
+ logging.log(logging.INFO, f"Dry run - skipping upload")
67
+ continue
68
+ item.upload(files={destination: filepath},
69
+ metadata=metadata,
70
+ queue_derive=False,
71
+ headers=headers,
72
+ verbose=True,
73
+ access_key=config.s3_key,
74
+ secret_key=config.s3_secret
75
+ )
76
+
77
+ # Final upload completions after all files are uploaded - update the metadata, run derives, etc
78
+ if config.dry_run:
79
+ logging.log(logging.INFO, f"Dry run - exiting early! Can't update metadata or derive an item that does not exist.")
80
+ return True
81
+
82
+ if not wait_for_item(identifier):
83
+ return False
84
+
85
+ if settings.set_upload_state:
86
+ metadata["upload-state"] = "uploaded"
87
+
88
+ item = internetarchive.get_item(identifier, archive_session=session)
89
+ metadata_changes = {}
90
+ item_metadata = item.metadata
91
+ # diff the metadata
92
+ # keys that are in metadata but not in item_metadata or keys that are present in both, but have different values
93
+ # ignore keys that are only in item_metadata
94
+ #print(item_metadata)
95
+ for key in metadata.keys():
96
+ if key == "description" and "<a" in metadata[key]:
97
+ # TODO: IA sanitizes description HTML (eg. with nofollow) so this will result in us always updating the description field...
98
+ # For now we just skip it
99
+ continue
100
+
101
+ if key not in item_metadata:
102
+ metadata_changes[key] = metadata[key]
103
+ continue
104
+
105
+ if isinstance(item_metadata[key], list) or isinstance(metadata[key], list):
106
+ item_value = item_metadata[key] if isinstance(item_metadata[key], list) else [item_metadata[key]]
107
+ metadata_value = metadata[key] if isinstance(metadata[key], list) else [metadata[key]]
108
+ value = list(set(item_value) | set(metadata_value))
109
+
110
+ if len(value) > len(item_value):
111
+ metadata_changes[key] = value
112
+ continue
113
+
114
+ if item_metadata[key] != metadata[key]:
115
+ metadata_changes[key] = metadata[key]
116
+
117
+
118
+ # if there are changes, update the metadata
119
+ if metadata_changes:
120
+ logging.log(logging.INFO, f"Updating metadata for {identifier}...")
121
+ logging.log(logging.INFO, f"Metadata changes: {json.dumps(metadata_changes, indent=4)}")
122
+ item.modify_metadata(metadata_changes)
123
+
124
+ if settings.derive:
125
+ # TODO: check that there is no queued/running derive task already
126
+ logging.log(logging.INFO, f"Deriving {identifier}...")
127
+ tasks = get_tasks(identifier, {"cmd":"derive.php", "history":"0"}, archive_session=session)
128
+ if tasks:
129
+ logging.log(logging.INFO, f"-> Found a derive task ({tasks.pop().task_id}) already running for {identifier} - see https://archive.org/history/{identifier}")
130
+ else:
131
+ ""
132
+ item.derive()
133
+
134
+ logging.log(logging.INFO, f"Success! Upload complete - {identifier} is now available at https://archive.org/details/{identifier}")
135
+
136
+ return True
137
+
138
+
139
+ def get_size(files: dict[str, str]) -> int:
140
+ size = 0
141
+ for file in files:
142
+ size += os.path.getsize(file)
143
+ return size
144
+
145
+ def get_files_to_upload(path: str, item: internetarchive.Item) -> dict[str, str]:
146
+ files: dict[str, str] = {}
147
+ for dirpath, dirnames, filenames in os.walk(path):
148
+ for f in filenames:
149
+ fp = os.path.join(dirpath, f)
150
+ # skip if it is symbolic link
151
+ if not os.path.islink(fp):
152
+ # get the relative path
153
+ rel_path = os.path.relpath(fp, path)
154
+ rel_path = rel_path.replace(os.path.sep, "/")
155
+ abs_path = os.path.abspath(fp)
156
+ files[abs_path] = rel_path
157
+
158
+ # remove __ia_meta.json and __uploader_meta.json from the list
159
+ for path, destination in list(files.items()):
160
+ if destination == "__ia_meta.json" or destination == "__uploader_meta.json":
161
+ files.pop(path, None)
162
+
163
+ if not item.exists:
164
+ return files
165
+
166
+ for path, destination in list(files.items()):
167
+ ia_file = next((file for file in item.files if file["name"] == destination), None)
168
+
169
+ if ia_file:
170
+ if ia_file["md5"] == hashlib.md5(open(path, "rb").read()).hexdigest():
171
+ logging.log(logging.INFO, f"{destination} already exists in {item.identifier}. Skipping...")
172
+ # remove the file from the list
173
+ files.pop(path, None)
174
+ continue
175
+ else:
176
+ raise Exception(f"{destination} already exists in {item.identifier}, but the hashes don't match.")
177
+
178
+ return files
179
+
180
+ def get_identifier(path: str) -> str:
181
+ if not os.path.isdir(path):
182
+ logging.log(logging.ERROR, f"{path} is not a directory")
183
+ return None
184
+
185
+ identifier: str = os.path.basename(path)
186
+
187
+ if not identifier:
188
+ logging.log(logging.ERROR, f"{path} does not have an identifier")
189
+ return None
190
+
191
+ # identifier validity: https://archive.org/developers/metadata-schema/index.html#archive-org-identifiers
192
+ if not re.match(r"^[a-zA-Z0-9-_.]{5,100}$", identifier):
193
+ logging.log(logging.ERROR, f"{identifier} is not a valid identifier. Identifiers should be 5-100 characters long and can only contain letters, numbers, dashes, underscores, and periods.")
194
+ return None
195
+
196
+ return identifier
197
+
198
+ def load_metadata(path: str) -> dict:
199
+ try:
200
+ with open(os.path.join(path, "__ia_meta.json"), "r") as meta_file:
201
+ metadata = meta_file.read()
202
+ if metadata:
203
+ return json.loads(metadata)
204
+ else:
205
+ return {}
206
+ except FileNotFoundError:
207
+ return {}
208
+
209
+ def load_uploader_settings(path: str) -> UploaderMeta:
210
+ try:
211
+ with open(os.path.join(path, "__uploader_meta.json"), "r") as meta_file:
212
+ settings_raw = meta_file.read()
213
+ if settings_raw:
214
+ return UploaderMeta.from_json(settings_raw)
215
+ else:
216
+ return UploaderMeta()
217
+ except FileNotFoundError:
218
+ return UploaderMeta()
219
+
220
+ def wait_for_item(identifier: str) -> bool:
221
+ # "borrowed" and modified from the wikiteam3 uploader
222
+ item = internetarchive.get_item(identifier)
223
+ tries = 400
224
+ for tries_left in range(tries, 0, -1):
225
+ if item.exists:
226
+ return True
227
+
228
+ logging.log(logging.INFO, msg=f"Waiting for the item to be created... ({tries_left} tries left) ...")
229
+ if tries < 395:
230
+ logging.log(logging.INFO, msg=f"Is IA overloaded? Still waiting for item to be created ({tries_left} tries left) ...")
231
+ time.sleep(30)
232
+ item = internetarchive.get_item(identifier)
233
+
234
+ if not item.exists:
235
+ logging.log(logging.ERROR, msg=f"IA overloaded, the item is still not ready after {400 * 30} seconds")
236
+ return False
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
3
+ build-backend = "poetry.core.masonry.api"
4
+
5
+ [project]
6
+ name = "dartboard-ia"
7
+ version = "0.2.1"
8
+ authors = [
9
+ {name = "DigitalDragon"}
10
+ ]
11
+ description = "Internet Archive upload management tool"
12
+ readme = "README.md"
13
+ requires-python = ">=3.8"
14
+ dependencies = [
15
+ "internetarchive>=5.0.3",
16
+ "dataclasses-json"
17
+ ]
18
+
19
+ [project.urls]
20
+ repository = "https://github.com/DigitalDwagon/dartboard"
21
+
22
+ [tool.poetry]
23
+ packages = [
24
+ {include = "dartboard"}
25
+ ]
26
+
27
+ [project.scripts]
28
+ dartboard = "dartboard.main:main"
29
+