Compare commits

..

No commits in common. 'main' and 'v0.0.7' have entirely different histories.
main ... v0.0.7

@ -3,15 +3,11 @@
This is a commandline tool for knotapi: https://gitlab.nic.cz/knot/knot-dns-rest This is a commandline tool for knotapi: https://gitlab.nic.cz/knot/knot-dns-rest
## Build and install ## Build and install
To install using pip, run the following command:
To install using pip, run the following command in a virtual envrionment.
``` ```
python -m pip install "knotctl @ git+https://code.smolnet.org/micke/knotctl pip3 install git+https://code.smolnet.org/micke/knotctl
``` ```
To build and install as a deb-package To build and install as a deb-package
``` ```
sudo apt install python3-stdeb sudo apt install python3-stdeb
git clone https://code.smolnet.org/micke/knotctl git clone https://code.smolnet.org/micke/knotctl
@ -22,27 +18,19 @@ sudo dpkg -i deb_dist/knotctl_*_all.deb
A prebuilt deb-package is also available from the release page: https://code.smolnet.org/micke/knotctl/releases/ A prebuilt deb-package is also available from the release page: https://code.smolnet.org/micke/knotctl/releases/
## Shell completion ## Shell completion
For bash: add this to .bashrc For bash: add this to .bashrc
``` ```
source <(knotctl completion) source <(knotctl completion)
``` ```
For fish, run: For fish, run:
``` ```
knotctl completion --shell fish > ~/.config/fish/completions/knotctl.fish knotctl completion --shell fish > ~/.config/fish/completions/knotctl.fish
``` ```
For tcsh: add this to .cshrc For tcsh: add this to .cshrc
``` ```
complete "knotctl" 'p@*@`python-argcomplete-tcsh "knotctl"`@' ; complete "knotctl" 'p@*@`python-argcomplete-tcsh "knotctl"`@' ;
``` ```
For zsh: add this to .zshrc For zsh: add this to .zshrc
``` ```
autoload -U bashcompinit autoload -U bashcompinit
bashcompinit bashcompinit
@ -51,69 +39,19 @@ source <(knotctl completion)
## Usage ## Usage
``` ```
usage: knotctl [-h] [--json | --no-json] usage: knotctl [-h] [--json | --no-json]
{add,auditlog,changelog,completion,config,delete,list,update} {add,completion,config,delete,list,update} ...
...
Manage DNS records with knot dns rest api:
* https://gitlab.nic.cz/knot/knot-dns-rest
positional arguments: positional arguments:
{add,auditlog,changelog,completion,config,delete,list,update} {add,completion,config,delete,list,update}
options: options:
-h, --help show this help message and exit -h, --help show this help message and exit
--json, --no-json --json, --no-json
The Domain Name System specifies a database of information
elements for network resources. The types of information
elements are categorized and organized with a list of DNS
record types, the resource records (RRs). Each record has a
name, a type, an expiration time (time to live), and
type-specific data.
The following is a list of terms used in this program:
----------------------------------------------------------------
| Vocabulary | Description |
----------------------------------------------------------------
| zone | A DNS zone is a specific portion of the DNS |
| | namespace in the Domain Name System (DNS), |
| | which a specific organization or administrator |
| | manages. |
----------------------------------------------------------------
| name | In the Internet, a domain name is a string that |
| | identifies a realm of administrative autonomy, |
| | authority or control. Domain names are often |
| | used to identify services provided through the |
| | Internet, such as websites, email services and |
| | more. |
----------------------------------------------------------------
| rtype | A record type indicates the format of the data |
| | and it gives a hint of its intended use. For |
| | example, the A record is used to translate from |
| | a domain name to an IPv4 address, the NS record |
| | lists which name servers can answer lookups on |
| | a DNS zone, and the MX record specifies the |
| | mail server used to handle mail for a domain |
| | specified in an e-mail address. |
----------------------------------------------------------------
| data | A records data is of type-specific relevance, |
| | such as the IP address for address records, or |
| | the priority and hostname for MX records. |
----------------------------------------------------------------
This information was compiled from Wikipedia:
* https://en.wikipedia.org/wiki/DNS_zone
* https://en.wikipedia.org/wiki/Domain_Name_System
* https://en.wikipedia.org/wiki/Zone_file
``` ```
### ADD ### ADD
``` ```
usage: knotctl add [-h] -d DATA -n NAME -r RTYPE [-t TTL] -z ZONE usage: knotctl add [-h] -d DATA -n NAME -r RTYPE [-t TTL] -z ZONE
Add a new record to the zone.
options: options:
-h, --help show this help message and exit -h, --help show this help message and exit
-d DATA, --data DATA -d DATA, --data DATA
@ -122,64 +60,29 @@ options:
-t TTL, --ttl TTL -t TTL, --ttl TTL
-z ZONE, --zone ZONE -z ZONE, --zone ZONE
``` ```
### COMPLETION ### COMPLETION
``` ```
usage: knotctl completion [-h] [-s SHELL] usage: knotctl completion [-h] [-s SHELL]
Generate shell completion script.
options: options:
-h, --help show this help message and exit -h, --help show this help message and exit
-s SHELL, --shell SHELL -s SHELL, --shell SHELL
``` ```
### AUDITLOG
```
usage: knotctl auditlog [-h]
Audit the log file for errors.
options:
-h, --help show this help message and exit
```
### CHANGELOG
```
usage: knotctl changelog [-h] -z ZONE
View the changelog of a zone.
options:
-h, --help show this help message and exit
-z ZONE, --zone ZONE
```
### CONFIG ### CONFIG
``` ```
usage: knotctl config [-h] [-b BASEURL] [-c CONTEXT] [-p PASSWORD] [-u USERNAME] usage: knotctl config [-h] [-c CONTEXT] [-b BASEURL] [-p PASSWORD] [-u USERNAME]
Configure access to knot-dns-rest-api.
options: options:
-h, --help show this help message and exit -h, --help show this help message and exit
-b BASEURL, --baseurl BASEURL
-c CONTEXT, --context CONTEXT -c CONTEXT, --context CONTEXT
-b BASEURL, --baseurl BASEURL
-p PASSWORD, --password PASSWORD -p PASSWORD, --password PASSWORD
-u USERNAME, --username USERNAME -u USERNAME, --username USERNAME
``` ```
### DELETE ### DELETE
``` ```
usage: knotctl delete [-h] [-d DATA] [-n NAME] [-r RTYPE] -z ZONE usage: knotctl delete [-h] [-d DATA] [-n NAME] [-r RTYPE] -z ZONE
Delete a record from the zone.
options: options:
-h, --help show this help message and exit -h, --help show this help message and exit
-d DATA, --data DATA -d DATA, --data DATA
@ -187,13 +90,9 @@ options:
-r RTYPE, --rtype RTYPE -r RTYPE, --rtype RTYPE
-z ZONE, --zone ZONE -z ZONE, --zone ZONE
``` ```
### LIST ### LIST
``` ```
usage: knotctl list [-h] [-d DATA] [-n NAME] [-r RTYPE] -z ZONE usage: knotctl list [-h] [-d DATA] [-n NAME] [-r RTYPE] [-z ZONE]
List records in the zone.
options: options:
-h, --help show this help message and exit -h, --help show this help message and exit
@ -202,32 +101,19 @@ options:
-r RTYPE, --rtype RTYPE -r RTYPE, --rtype RTYPE
-z ZONE, --zone ZONE -z ZONE, --zone ZONE
``` ```
### UPDATE ### UPDATE
``` ```
usage: knotctl update [-h] -a [ARGUMENT ...] -d DATA -n NAME -r RTYPE [-t TTL] usage: knotctl update [-h] -a [ARGUMENT ...] -d DATA -n NAME -r RTYPE [-t TTL]
-z ZONE -z ZONE
Update a record in the zone. The record must exist in the zone.
In this case --data, --name, --rtype and --ttl switches are used
for searching for the appropriate record, while the --argument
switches are used for updating the record.
options: options:
-h, --help show this help message and exit -h, --help show this help message and exit
-a [KEY=VALUE ...], --argument [KEY=VALUE ...] -a [ARGUMENT ...], --argument [ARGUMENT ...]
Specify key - value pairs to be updated: Specify key - value pairs to be updated:
name=dns1.example.com. or data=127.0.0.1 for example. name=dns1.example.com.
--argument can be repeated
-d DATA, --data DATA -d DATA, --data DATA
-n NAME, --name NAME -n NAME, --name NAME
-r RTYPE, --rtype RTYPE -r RTYPE, --rtype RTYPE
-t TTL, --ttl TTL -t TTL, --ttl TTL
-z ZONE, --zone ZONE -z ZONE, --zone ZONE
Available arguments are:
data: New record data.
name: New record domain name.
rtype: New record type.
ttl: New record time to live (TTL).
``` ```

@ -1,37 +0,0 @@
[build-system]
requires = ["flit_core >=3.2,<4"]
build-backend = "flit_core.buildapi"
[project]
name="knotctl"
description="A CLI for knotapi."
authors = [
{name = "Micke Nordin", email = "hej@mic.ke"},
]
license= { file="LICENSE" }
readme= "README.md"
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
]
requires-python= ">=3.9"
version = "0.1.0"
dependencies = [
"argcomplete==2.0.0",
"pyyaml==6.0.1",
"requests==2.27.1",
"simplejson==3.17.6",
]
[project.urls]
Source="https://code.smolnet.org/micke/knotctl"
Documentation = "https://code.smolnet.org/micke/knotctl"
[project.scripts]
knotctl="knotctl:main"
[tool.flit.sdist]
include = ["LICENSE",]

@ -1,4 +1,4 @@
argcomplete==2.0.0 argcomplete==2.0.0
pyyaml==6.0.1 pyyaml==5.4.1
requests==2.27.1 requests==2.27.1
simplejson==3.17.6 simplejson==3.17.6

@ -7,7 +7,7 @@ import os
import sys import sys
import urllib.parse import urllib.parse
from os import environ, mkdir from os import environ, mkdir
from os.path import isdir, isfile, islink, join, split from os.path import isdir, isfile, join
from typing import Union from typing import Union
from urllib.parse import urlparse from urllib.parse import urlparse
@ -16,7 +16,6 @@ import requests
import yaml import yaml
from requests.models import HTTPBasicAuth from requests.models import HTTPBasicAuth
from simplejson.errors import JSONDecodeError as SimplejsonJSONDecodeError from simplejson.errors import JSONDecodeError as SimplejsonJSONDecodeError
try: try:
from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError
except ImportError: except ImportError:
@ -46,7 +45,8 @@ def nested_out(input, tabs="") -> str:
string += "{}\n".format(input) string += "{}\n".format(input)
elif isinstance(input, dict): elif isinstance(input, dict):
for key, value in input.items(): for key, value in input.items():
string += "{}{}: {}".format(tabs, key, nested_out(value, tabs + " ")) string += "{}{}: {}".format(tabs, key,
nested_out(value, tabs + " "))
elif isinstance(input, list): elif isinstance(input, list):
for entry in input: for entry in input:
string += "{}\n{}".format(tabs, nested_out(entry, tabs + " ")) string += "{}\n{}".format(tabs, nested_out(entry, tabs + " "))
@ -70,60 +70,15 @@ def run_add(url: str, jsonout: bool, headers: dict):
out = response.json() out = response.json()
if isinstance(out, list): if isinstance(out, list):
for record in out: for record in out:
if ( if (record["data"] == parsed["data"]
record["data"] == parsed["data"] and record["name"] == parsed["name"]
and record["name"] == parsed["name"] and record["rtype"] == parsed["rtype"]):
and record["rtype"] == parsed["rtype"]
):
output(record, jsonout) output(record, jsonout)
break break
else: else:
output(out, jsonout) output(out, jsonout)
def run_log(url: str, jsonout: bool, headers: dict):
response = requests.get(url, headers=headers)
string = response.content.decode("utf-8")
if jsonout:
out = []
lines = string.splitlines()
index = 0
text = ""
timestamp = ""
while index < len(lines):
line = lines[index]
index += 1
cur_has_timestamp = line.startswith("[")
next_has_timestamp = index < len(lines) and lines[index].startswith(
"["
)
# Simple case, just one line with timestamp
if cur_has_timestamp and next_has_timestamp:
timestamp = line.split(']')[0].split('[')[1]
text = line.split(']')[1].lstrip(':').strip()
out.append({'timestamp': timestamp, 'text': text})
text = ""
timestamp = ""
# Start of multiline
elif cur_has_timestamp:
timestamp = line.split(']')[0].split('[')[1]
text = line.split(']')[1].lstrip(':').strip()
# End of multiline
elif next_has_timestamp:
text += f'\n{line.strip()}'
out.append({'timestamp': timestamp, 'text': text})
text = ""
timestamp = ""
# Middle of multiline
else:
text += f'\n{line.strip()}'
else:
out = string
output(out, jsonout)
def run_complete(shell: Union[None, str]): def run_complete(shell: Union[None, str]):
if not shell or shell in ["bash", "zsh"]: if not shell or shell in ["bash", "zsh"]:
os.system("register-python-argcomplete knotctl") os.system("register-python-argcomplete knotctl")
@ -139,19 +94,11 @@ def run_config(
baseurl: Union[None, str] = None, baseurl: Union[None, str] = None,
username: Union[None, str] = None, username: Union[None, str] = None,
password: Union[None, str] = None, password: Union[None, str] = None,
current: Union[None, str] = None,
): ):
if current:
if os.path.islink(config_filename):
actual_path = os.readlink(config_filename)
print(actual_path.split('-')[-1])
else:
print("none")
return
config = {"baseurl": baseurl, "username": username, "password": password} config = {"baseurl": baseurl, "username": username, "password": password}
needed = [] needed = []
if context: if context:
symlink = f"{config_filename}-{context}" symlink = f'{config_filename}-{context}'
found = os.path.isfile(symlink) found = os.path.isfile(symlink)
if os.path.islink(config_filename): if os.path.islink(config_filename):
os.remove(config_filename) os.remove(config_filename)
@ -171,8 +118,7 @@ def run_config(
error( error(
"Can not configure without {}".format(need), "Can not configure without {}".format(need),
"No {}".format(need), "No {}".format(need),
) ))
)
sys.exit(1) sys.exit(1)
config[need] = input("Enter {}: ".format(need)) config[need] = input("Enter {}: ".format(need))
@ -196,7 +142,10 @@ def run_delete(url: str, jsonout: bool, headers: dict):
output(reply, jsonout) output(reply, jsonout)
def run_list(url: str, jsonout: bool, headers: dict, ret=False) -> Union[None, str]: def run_list(url: str,
jsonout: bool,
headers: dict,
ret=False) -> Union[None, str]:
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers)
string = response.json() string = response.json()
if ret: if ret:
@ -227,7 +176,7 @@ def setup_url(
url += "/{}".format(zone) url += "/{}".format(zone)
if name and zone: if name and zone:
if name.endswith(zone.rstrip(".")): if name.endswith(zone.rstrip(".")):
name += "." name += '.'
url += "/records/{}".format(name) url += "/records/{}".format(name)
if zone and name and rtype: if zone and name and rtype:
url += "/{}".format(rtype) url += "/{}".format(rtype)
@ -248,16 +197,14 @@ def setup_url(
error( error(
"ttl only makes sense with rtype, name and zone", "ttl only makes sense with rtype, name and zone",
"Missing parameter", "Missing parameter",
) ))
)
sys.exit(1) sys.exit(1)
if rtype and (not name or not zone): if rtype and (not name or not zone):
output( output(
error( error(
"rtype only makes sense with name and zone", "rtype only makes sense with name and zone",
"Missing parameter", "Missing parameter",
) ))
)
sys.exit(1) sys.exit(1)
if name and not zone: if name and not zone:
output(error("name only makes sense with a zone", "Missing parameter")) output(error("name only makes sense with a zone", "Missing parameter"))
@ -299,129 +246,44 @@ def split_url(url: str) -> dict:
# Entry point to program # Entry point to program
def main() -> int: def main() -> int:
description = """Manage DNS records with knot dns rest api:
* https://gitlab.nic.cz/knot/knot-dns-rest"""
epilog = """
The Domain Name System specifies a database of information
elements for network resources. The types of information
elements are categorized and organized with a list of DNS
record types, the resource records (RRs). Each record has a
name, a type, an expiration time (time to live), and
type-specific data.
The following is a list of terms used in this program:
----------------------------------------------------------------
| Vocabulary | Description |
----------------------------------------------------------------
| zone | A DNS zone is a specific portion of the DNS |
| | namespace in the Domain Name System (DNS), |
| | which a specific organization or administrator |
| | manages. |
----------------------------------------------------------------
| name | In the Internet, a domain name is a string that |
| | identifies a realm of administrative autonomy, |
| | authority or control. Domain names are often |
| | used to identify services provided through the |
| | Internet, such as websites, email services and |
| | more. |
----------------------------------------------------------------
| rtype | A record type indicates the format of the data |
| | and it gives a hint of its intended use. For |
| | example, the A record is used to translate from |
| | a domain name to an IPv4 address, the NS record |
| | lists which name servers can answer lookups on |
| | a DNS zone, and the MX record specifies the |
| | mail server used to handle mail for a domain |
| | specified in an e-mail address. |
----------------------------------------------------------------
| data | A records data is of type-specific relevance, |
| | such as the IP address for address records, or |
| | the priority and hostname for MX records. |
----------------------------------------------------------------
This information was compiled from Wikipedia:
* https://en.wikipedia.org/wiki/DNS_zone
* https://en.wikipedia.org/wiki/Domain_Name_System
* https://en.wikipedia.org/wiki/Zone_file
"""
# Grab user input # Grab user input
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser()
description=description,
epilog=epilog,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--json", action=argparse.BooleanOptionalAction) parser.add_argument("--json", action=argparse.BooleanOptionalAction)
subparsers = parser.add_subparsers(dest="command") subparsers = parser.add_subparsers(dest="command")
addcmd = subparsers.add_parser("add")
add_description = "Add a new record to the zone."
addcmd = subparsers.add_parser("add", description=add_description)
addcmd.add_argument("-d", "--data", required=True) addcmd.add_argument("-d", "--data", required=True)
addcmd.add_argument("-n", "--name", required=True) addcmd.add_argument("-n", "--name", required=True)
addcmd.add_argument("-r", "--rtype", required=True) addcmd.add_argument("-r", "--rtype", required=True)
addcmd.add_argument("-t", "--ttl") addcmd.add_argument("-t", "--ttl")
addcmd.add_argument("-z", "--zone", required=True) addcmd.add_argument("-z", "--zone", required=True)
auditlog_description = "Audit the log file for errors." completecmd = subparsers.add_parser("completion")
subparsers.add_parser("auditlog", description=auditlog_description)
changelog_description = "View the changelog of a zone."
changelogcmd = subparsers.add_parser("changelog", description=changelog_description)
changelogcmd.add_argument("-z", "--zone", required=True)
complete_description = "Generate shell completion script."
completecmd = subparsers.add_parser("completion", description=complete_description)
completecmd.add_argument("-s", "--shell") completecmd.add_argument("-s", "--shell")
config_description = "Configure access to knot-dns-rest-api." configcmd = subparsers.add_parser("config")
configcmd = subparsers.add_parser("config", description=config_description)
configcmd.add_argument("-b", "--baseurl") configcmd.add_argument("-b", "--baseurl")
configcmd.add_argument("-c", "--context") configcmd.add_argument("-c", "--context")
configcmd.add_argument("-C", "--current", action=argparse.BooleanOptionalAction)
configcmd.add_argument("-p", "--password") configcmd.add_argument("-p", "--password")
configcmd.add_argument("-u", "--username") configcmd.add_argument("-u", "--username")
delete_description = "Delete a record from the zone." deletecmd = subparsers.add_parser("delete")
deletecmd = subparsers.add_parser("delete", description=delete_description)
deletecmd.add_argument("-d", "--data") deletecmd.add_argument("-d", "--data")
deletecmd.add_argument("-n", "--name") deletecmd.add_argument("-n", "--name")
deletecmd.add_argument("-r", "--rtype") deletecmd.add_argument("-r", "--rtype")
deletecmd.add_argument("-z", "--zone", required=True) deletecmd.add_argument("-z", "--zone", required=True)
list_description = "List records in the zone." listcmd = subparsers.add_parser("list")
listcmd = subparsers.add_parser("list", description=list_description)
listcmd.add_argument("-d", "--data") listcmd.add_argument("-d", "--data")
listcmd.add_argument("-n", "--name") listcmd.add_argument("-n", "--name")
listcmd.add_argument("-r", "--rtype") listcmd.add_argument("-r", "--rtype")
listcmd.add_argument("-z", "--zone", required=True) listcmd.add_argument("-z", "--zone", required=True)
update_description = ( updatecmd = subparsers.add_parser("update")
"Update a record in the zone. The record must exist in the zone.\n"
)
update_description += (
"In this case --data, --name, --rtype and --ttl switches are used\n"
)
update_description += (
"for searching for the appropriate record, while the --argument\n"
)
update_description += "switches are used for updating the record."
update_epilog = """Available arguments are:
data: New record data.
name: New record domain name.
rtype: New record type.
ttl: New record time to live (TTL)."""
updatecmd = subparsers.add_parser(
"update",
description=update_description,
epilog=update_epilog,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
updatecmd.add_argument( updatecmd.add_argument(
"-a", "-a",
"--argument", "--argument",
action="append", nargs="*",
metavar="KEY=VALUE", help="Specify key - value pairs to be updated: name=dns1.example.com.",
help="Specify key - value pairs to be updated: name=dns1.example.com. or data=127.0.0.1 for example. --argument can be repeated",
required=True, required=True,
) )
updatecmd.add_argument("-d", "--data", required=True) updatecmd.add_argument("-d", "--data", required=True)
@ -444,9 +306,7 @@ def main() -> int:
mkdir(config_basepath) mkdir(config_basepath)
if args.command == "config": if args.command == "config":
run_config( run_config(config_filename, args.context, args.baseurl, args.username, args.password)
config_filename, args.context, args.baseurl, args.username, args.password, args.current
)
return 0 return 0
if not isfile(config_filename): if not isfile(config_filename):
@ -467,12 +327,12 @@ def main() -> int:
output(response.json()) output(response.json())
return 1 return 1
except requests.exceptions.JSONDecodeError: except requests.exceptions.JSONDecodeError:
output(error("Could not decode api response as JSON", "Could not decode")) output(
error("Could not decode api response as JSON", "Could not decode"))
return 1 return 1
headers = {"Authorization": "Bearer {}".format(token)} headers = {"Authorization": "Bearer {}".format(token)}
# Route based on command # Route based on command
url = ""
ttl = None ttl = None
if "ttl" in args: if "ttl" in args:
ttl = args.ttl ttl = args.ttl
@ -486,22 +346,20 @@ def main() -> int:
soa_url = setup_url(baseurl, None, None, zname, "SOA", None, args.zone) soa_url = setup_url(baseurl, None, None, zname, "SOA", None, args.zone)
soa_json = run_list(soa_url, True, headers, ret=True) soa_json = run_list(soa_url, True, headers, ret=True)
ttl = soa_json[0]["ttl"] ttl = soa_json[0]["ttl"]
if args.command in ["auditlog", "changelog"]:
pass try:
else: url = setup_url(
try: baseurl,
url = setup_url( args.argument,
baseurl, args.data,
args.argument, args.name,
args.data, args.rtype,
args.name, ttl,
args.rtype, args.zone,
ttl, )
args.zone, except AttributeError:
) parser.print_help(sys.stderr)
except AttributeError: return 1
parser.print_help(sys.stderr)
return 1
try: try:
if args.command == "add": if args.command == "add":
@ -512,19 +370,9 @@ def main() -> int:
run_list(url, args.json, headers) run_list(url, args.json, headers)
elif args.command == "update": elif args.command == "update":
run_update(url, args.json, headers) run_update(url, args.json, headers)
elif args.command == "auditlog":
url = baseurl + "/user/auditlog"
run_log(url, args.json, headers)
elif args.command == "changelog":
url = baseurl + f"/zones/changelog/{args.zone.rstrip('.')}"
run_log(url, args.json, headers)
else:
parser.print_help(sys.stderr)
return 2
except requests.exceptions.RequestException as e:
output(error(e, "Could not connect to server"))
except (RequestsJSONDecodeError, SimplejsonJSONDecodeError): except (RequestsJSONDecodeError, SimplejsonJSONDecodeError):
output(error("Could not decode api response as JSON", "Could not decode")) output(
error("Could not decode api response as JSON", "Could not decode"))
return 0 return 0

@ -0,0 +1,26 @@
import setuptools
with open("README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
setuptools.setup(
name="knotctl",
version="0.0.7",
packages=setuptools.find_packages(),
author="Micke Nordin",
author_email="hej@mic.ke",
description="A cli for knotapi.",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://code.smolnet.org/micke/knotctl",
project_urls={
"Bug Tracker": "https://code.smolnet.org/micke/knotctl/issues",
},
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GPL-3.0",
"Operating System :: OS Independent",
],
python_requires=">=3.9",
scripts=["scripts/knotctl"],
)
Loading…
Cancel
Save