From 7e7656e22ced47bec5ea5bae1da08e3ef48d2e42 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Tue, 4 Apr 2023 10:23:20 +0200 Subject: worktime... --- overlays/worktime/default.nix | 28 +- overlays/worktime/poetry.lock | 248 +++++++++++++ overlays/worktime/pyproject.toml | 22 ++ overlays/worktime/shell.nix | 5 - overlays/worktime/worktime.py | 619 --------------------------------- overlays/worktime/worktime/__init__.py | 0 overlays/worktime/worktime/__main__.py | 617 ++++++++++++++++++++++++++++++++ 7 files changed, 896 insertions(+), 643 deletions(-) create mode 100644 overlays/worktime/poetry.lock create mode 100644 overlays/worktime/pyproject.toml delete mode 100644 overlays/worktime/shell.nix delete mode 100755 overlays/worktime/worktime.py create mode 100644 overlays/worktime/worktime/__init__.py create mode 100755 overlays/worktime/worktime/__main__.py (limited to 'overlays/worktime') diff --git a/overlays/worktime/default.nix b/overlays/worktime/default.nix index 699e00c0..7f5865ee 100644 --- a/overlays/worktime/default.nix +++ b/overlays/worktime/default.nix @@ -1,25 +1,15 @@ -{ prev, ... }: { - worktime = prev.stdenv.mkDerivation rec { - name = "worktime"; - src = ./worktime.py; +{ prev, ... }: - phases = [ "buildPhase" "checkPhase" "installPhase" ]; +with prev.poetry2nix; - python = prev.python39.withPackages (ps: with ps; [pyxdg python-dateutil uritools requests configparser tabulate backoff]); - buildInputs = [ python ]; +{ + worktime = mkPoetryApplication { + python = prev.python310; - buildPhase = '' - substituteAll $src worktime - ''; + projectDir = cleanPythonSources { src = ./.; }; - doCheck = true; - checkPhase = '' - python -m py_compile worktime - ''; - - installPhase = '' - install -m 0755 -D -t $out/bin \ - worktime - ''; + overrides = overrides.withDefaults (self: super: { + inherit (prev.python310.pkgs) tabulate; + }); }; } diff --git a/overlays/worktime/poetry.lock b/overlays/worktime/poetry.lock new file mode 100644 index 00000000..7a9a9b86 --- /dev/null +++ b/overlays/worktime/poetry.lock @@ -0,0 +1,248 @@ +# This file is automatically @generated by Poetry and should not be changed by hand. + +[[package]] +name = "backoff" +version = "2.2.1" +description = "Function decoration for backoff and retry" +category = "main" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, + {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, +] + +[[package]] +name = "certifi" +version = "2022.12.7" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, + {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.1.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, + {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, + {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, + {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, + {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, + {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, + {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, +] + +[[package]] +name = "configparser" +version = "5.3.0" +description = "Updated configparser from stdlib for earlier Pythons." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "configparser-5.3.0-py3-none-any.whl", hash = "sha256:b065779fd93c6bf4cee42202fa4351b4bb842e96a3fb469440e484517a49b9fa"}, + {file = "configparser-5.3.0.tar.gz", hash = "sha256:8be267824b541c09b08db124917f48ab525a6c3e837011f3130781a224c57090"}, +] + +[package.extras] +docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] +testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "types-backports"] + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pyxdg" +version = "0.28" +description = "PyXDG contains implementations of freedesktop.org standards in python." +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "pyxdg-0.28-py2.py3-none-any.whl", hash = "sha256:bdaf595999a0178ecea4052b7f4195569c1ff4d344567bccdc12dfdf02d545ab"}, + {file = "pyxdg-0.28.tar.gz", hash = "sha256:3267bb3074e934df202af2ee0868575484108581e6f3cb006af1da35395e88b4"}, +] + +[[package]] +name = "requests" +version = "2.28.2" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" +files = [ + {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"}, + {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "tabulate" +version = "0.9.0" +description = "Pretty-print tabular data" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, + {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, +] + +[package.extras] +widechars = ["wcwidth"] + +[[package]] +name = "uritools" +version = "4.0.1" +description = "URI parsing, classification and composition" +category = "main" +optional = false +python-versions = "~=3.7" +files = [ + {file = "uritools-4.0.1-py3-none-any.whl", hash = "sha256:d122d394ed6e6e15ac0fddba6a5b19e9fa204e7797507815cbfb0e1455ac0475"}, + {file = "uritools-4.0.1.tar.gz", hash = "sha256:efc5c3a6de05404850685a8d3f34da8476b56aa3516fbf8eff5c8704c7a2826f"}, +] + +[[package]] +name = "urllib3" +version = "1.26.15" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +files = [ + {file = "urllib3-1.26.15-py2.py3-none-any.whl", hash = "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42"}, + {file = "urllib3-1.26.15.tar.gz", hash = "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.10" +content-hash = "30e7918385e12f686b92da70a0be69cd22879892239b65e399828c24d13ca262" diff --git a/overlays/worktime/pyproject.toml b/overlays/worktime/pyproject.toml new file mode 100644 index 00000000..f3fd3dfa --- /dev/null +++ b/overlays/worktime/pyproject.toml @@ -0,0 +1,22 @@ +[tool.poetry] +name = "worktime" +version = "0.1.0" +description = "" +authors = ["Gregor Kleen "] + +[tool.poetry.dependencies] +python = "^3.10" +pyxdg = "^0.28" +python-dateutil = "^2.8.2" +uritools = "^4.0.1" +requests = "^2.28.2" +configparser = "^5.3.0" +tabulate = "^0.9.0" +backoff = "^2.2.1" + +[tool.poetry.scripts] +worktime = "worktime.__main__:main" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/overlays/worktime/shell.nix b/overlays/worktime/shell.nix deleted file mode 100644 index 18d2a68a..00000000 --- a/overlays/worktime/shell.nix +++ /dev/null @@ -1,5 +0,0 @@ -{ pkgs ? import {} }: - -pkgs.mkShell { - buildInputs = [(pkgs.python310.withPackages (ps: with ps; [pyxdg python-dateutil uritools requests configparser tabulate]))]; -} diff --git a/overlays/worktime/worktime.py b/overlays/worktime/worktime.py deleted file mode 100755 index 46197b6e..00000000 --- a/overlays/worktime/worktime.py +++ /dev/null @@ -1,619 +0,0 @@ -#!@python@/bin/python - -import requests -from requests.exceptions import HTTPError -from requests.auth import HTTPBasicAuth -from datetime import * -from xdg import (BaseDirectory) -import configparser -from uritools import uricompose - -from dateutil.easter import * -from dateutil.tz import * -from dateutil.parser import isoparse - -from enum import Enum - -from math import (copysign, ceil, floor) - -import calendar - -import argparse - -from copy import deepcopy - -import sys -from sys import stderr - -from tabulate import tabulate - -from itertools import groupby -from functools import cache - -import backoff - - -class TogglAPISection(Enum): - TOGGL = '/api/v8' - REPORTS = '/reports/api/v2' - -class TogglAPIError(Exception): - def __init__(self, http_error, response): - self.http_error = http_error - self.response = response - - def __str__(self): - if not self.http_error is None: - return str(self.http_error) - else: - return self.response.text - -class TogglAPI(object): - def __init__(self, api_token, workspace_id, client_ids): - self._api_token = api_token - self._workspace_id = workspace_id - self._client_ids = set(map(int, client_ids.split(','))) if client_ids else None - - def _make_url(self, api=TogglAPISection.TOGGL, section=['time_entries', 'current'], params={}): - if api is TogglAPISection.REPORTS: - params.update({'user_agent': 'worktime', 'workspace_id': self._workspace_id}) - - api_path = api.value - section_path = '/'.join(section) - uri = uricompose(scheme='https', host='api.track.toggl.com', path=f"{api_path}/{section_path}", query=params) - - return uri - - def _query(self, url, method): - response = self._raw_query(url, method) - response.raise_for_status() - return response - - @backoff.on_predicate( - backoff.expo, - factor=0.1, max_value=2, - predicate=lambda r: r.status_code == 429, - max_time=10, - ) - def _raw_query(self, url, method): - headers = {'content-type': 'application/json'} - response = None - - if method == 'GET': - response = requests.get(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token')) - elif method == 'POST': - response = requests.post(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token')) - else: - raise ValueError(f"Undefined HTTP method “{method}”") - - return response - - def get_billable_hours(self, start_date, end_date=datetime.now(timezone.utc), rounding=False): - billable_acc = timedelta(milliseconds = 0) - step = timedelta(days = 365) - - for req_start in [start_date + x * step for x in range(0, ceil((end_date - start_date) / step))]: - req_end = end_date - if end_date > req_start + step: - req_end = datetime.combine((req_start + step).astimezone(timezone.utc).date(), time(tzinfo=timezone.utc)) - elif req_start > start_date: - req_start = datetime.combine(req_start.astimezone(timezone.utc).date(), time(tzinfo=timezone.utc)) + timedelta(days = 1) - - def get_report(client_ids = self._client_ids): - nonlocal req_start, req_end, rounding, self - - if client_ids is not None and not client_ids: - return timedelta(milliseconds = 0) - - params = { 'since': req_start.astimezone(timezone.utc).isoformat(), - 'until': req_end.astimezone(timezone.utc).isoformat(), - 'rounding': rounding, - 'billable': 'yes' - } - if client_ids is not None: - params |= { 'client_ids': ','.join(map(str, client_ids)) } - url = self._make_url(api = TogglAPISection.REPORTS, section = ['summary'], params = params) - r = self._query(url = url, method='GET') - if not r or not r.json(): - raise TogglAPIError(r) - res = timedelta(milliseconds=r.json()['total_billable']) if r.json()['total_billable'] else timedelta(milliseconds=0) - return res - - if 0 in self._client_ids: - url = self._make_url(api = TogglAPISection.TOGGL, section = ['workspaces', self._workspace_id, 'clients']) - r = self._query(url = url, method = 'GET') - if not r or not r.json(): - raise TogglAPIError(r) - - billable_acc += get_report(None) - get_report(set(map(lambda c: c['id'], r.json()))) - - billable_acc += get_report(self._client_ids - {0}) - - return billable_acc - - def get_running_clock(self, now=datetime.now(timezone.utc)): - url = self._make_url(api = TogglAPISection.TOGGL, section = ['time_entries', 'current']) - r = self._query(url = url, method='GET') - - if not r or not r.json(): - raise TogglAPIError(r) - - if not r.json()['data'] or not r.json()['data']['billable']: - return None - - if self._client_ids is not None: - if 'pid' in r.json()['data'] and r.json()['data']['pid']: - url = self._make_url(api = TogglAPISection.TOGGL, section = ['projects', str(r.json()['data']['pid'])]) - pr = self._query(url = url, method = 'GET') - if not pr or not pr.json(): - raise TogglAPIError(pr) - - if not pr.json()['data']: - return None - - if 'cid' in pr.json()['data'] and pr.json()['data']['cid']: - if pr.json()['data']['cid'] not in self._client_ids: - return None - elif 0 not in self._client_ids: - return None - elif 0 not in self._client_ids: - return None - - start = isoparse(r.json()['data']['start']) - - return now - start if start <= now else None - -class Worktime(object): - time_worked = timedelta() - running_entry = None - now = datetime.now(tzlocal()) - time_pulled_forward = timedelta() - is_workday = False - include_running = True - time_to_work = None - force_day_to_work = True - leave_days = set() - leave_budget = dict() - time_per_day = None - workdays = None - - @staticmethod - @cache - def holidays(year): - holidays = dict() - - y_easter = datetime.combine(easter(year), time(), tzinfo=tzlocal()) - - # Legal holidays in munich, bavaria - holidays[datetime(year, 1, 1, tzinfo=tzlocal()).date()] = 1 - holidays[datetime(year, 1, 6, tzinfo=tzlocal()).date()] = 1 - holidays[(y_easter+timedelta(days=-2)).date()] = 1 - holidays[(y_easter+timedelta(days=+1)).date()] = 1 - holidays[datetime(year, 5, 1, tzinfo=tzlocal()).date()] = 1 - holidays[(y_easter+timedelta(days=+39)).date()] = 1 - holidays[(y_easter+timedelta(days=+50)).date()] = 1 - holidays[(y_easter+timedelta(days=+60)).date()] = 1 - holidays[datetime(year, 8, 15, tzinfo=tzlocal()).date()] = 1 - holidays[datetime(year, 10, 3, tzinfo=tzlocal()).date()] = 1 - holidays[datetime(year, 11, 1, tzinfo=tzlocal()).date()] = 1 - holidays[datetime(year, 12, 25, tzinfo=tzlocal()).date()] = 1 - holidays[datetime(year, 12, 26, tzinfo=tzlocal()).date()] = 1 - - return holidays - - @staticmethod - def config(): - config = configparser.ConfigParser() - config_dir = BaseDirectory.load_first_config('worktime') - config.read(f"{config_dir}/worktime.ini") - return config - - def ordinal_workday(self, date): - start_date = datetime(date.year, 1, 1, tzinfo=tzlocal()).date() - return len([1 for offset in range(0, (date - start_date).days + 1) if self.would_be_workday(start_date + timedelta(days = offset))]) - - def would_be_workday(self, date): - return date.isoweekday() in self.workdays and date not in set(day for (day, val) in Worktime.holidays(date.year).items() if val >= 1) - - def __init__(self, start_datetime=None, end_datetime=None, now=None, include_running=True, force_day_to_work=True, **kwargs): - self.include_running = include_running - self.force_day_to_work = force_day_to_work - - if now: - self.now = now - - config = Worktime.config() - config_dir = BaseDirectory.load_first_config('worktime') - api = TogglAPI(api_token=config['TOGGL']['ApiToken'], workspace_id=config['TOGGL']['Workspace'], client_ids=config.get('TOGGL', 'ClientIds', fallback=None)) - date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d') - - start_date = start_datetime or datetime.strptime(config['WORKTIME']['StartDate'], date_format).replace(tzinfo=tzlocal()) - end_date = end_datetime or self.now - - try: - with open(f"{config_dir}/reset", 'r') as reset: - for line in reset: - stripped_line = line.strip() - reset_date = datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()) - - if reset_date > start_date and reset_date <= end_date: - start_date = reset_date - except IOError as e: - if e.errno != 2: - raise e - - - hours_per_week = float(config.get('WORKTIME', 'HoursPerWeek', fallback=40)) - self.workdays = set([int(d.strip()) for d in config.get('WORKTIME', 'Workdays', fallback='1,2,3,4,5').split(',')]) - self.time_per_day = timedelta(hours = hours_per_week) / len(self.workdays) - - holidays = dict() - - leave_per_year = int(config.get('WORKTIME', 'LeavePerYear', fallback=30)) - for year in range(start_date.year, end_date.year + 1): - holidays |= {k: v * self.time_per_day for k, v in Worktime.holidays(year).items()} - leave_frac = 1 - if date(year, 1, 1) < start_date.date(): - leave_frac = (date(year + 1, 1, 1) - start_date.date()) / (date(year + 1, 1, 1) - date(year, 1, 1)) - self.leave_budget |= {year: floor(leave_per_year * leave_frac)} - - try: - with open(f"{config_dir}/reset-leave", 'r') as excused: - for line in excused: - stripped_line = line.strip() - if stripped_line: - [datestr, count] = stripped_line.split(' ') - day = datetime.strptime(datestr, date_format).replace(tzinfo=tzlocal()).date() - if day != start_date.date(): - continue - - self.leave_budget[day.year] = (self.leave_budget[day.year] if day.year in self.leave_budget else 0) + int(count) - except IOError as e: - if e.errno != 2: - raise e - - - for excused_kind in {'excused', 'leave'}: - try: - with open(f"{config_dir}/{excused_kind}", 'r') as excused: - for line in excused: - stripped_line = line.strip() - if stripped_line: - splitLine = stripped_line.split(' ') - fromDay = toDay = None - def parse_datestr(datestr): - nonlocal fromDay, toDay - def parse_single(singlestr): - return datetime.strptime(singlestr, date_format).replace(tzinfo=tzlocal()).date() - if '--' in datestr: - [fromDay,toDay] = datestr.split('--') - fromDay = parse_single(fromDay) - toDay = parse_single(toDay) - else: - fromDay = toDay = parse_single(datestr) - time = self.time_per_day - if len(splitLine) == 2: - [hours, datestr] = splitLine - time = timedelta(hours = float(hours)) - parse_datestr(datestr) - else: - parse_datestr(stripped_line) - - for day in [fromDay + timedelta(days = x) for x in range(0, (toDay - fromDay).days + 1)]: - if end_date.date() < day or day < start_date.date(): - continue - - if excused_kind == 'leave' and self.would_be_workday(day): - self.leave_days.add(day) - holidays[day] = time - except IOError as e: - if e.errno != 2: - raise e - - pull_forward = dict() - - start_day = start_date.date() - end_day = end_date.date() - - try: - with open(f"{config_dir}/pull-forward", 'r') as excused: - for line in excused: - stripped_line = line.strip() - if stripped_line: - [hours, datestr] = stripped_line.split(' ') - constr = datestr.split(',') - for d in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1 + int(timedelta(hours = float(hours)).total_seconds() / 60 * (7 / len(self.workdays)) * 2))]: - for c in constr: - if c in calendar.day_abbr: - if not d.strftime('%a') == c: break - elif "--" in c: - [fromDay,toDay] = c.split('--') - if fromDay != "": - fromDay = datetime.strptime(fromDay, date_format).replace(tzinfo=tzlocal()).date() - if not fromDay <= d: break - if toDay != "": - toDay = datetime.strptime(toDay, date_format).replace(tzinfo=tzlocal()).date() - if not d <= toDay: break - else: - if not d == datetime.strptime(c, date_format).replace(tzinfo=tzlocal()).date(): break - else: - if d >= end_date.date(): - pull_forward[d] = min(timedelta(hours = float(hours)), self.time_per_day - (holidays[d] if d in holidays else timedelta())) - except IOError as e: - if e.errno != 2: - raise e - - days_to_work = dict() - - if pull_forward: - end_day = max(end_day, max(list(pull_forward))) - - for day in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1)]: - if day.isoweekday() in self.workdays: - time_to_work = self.time_per_day - if day in holidays.keys(): - time_to_work -= holidays[day] - if time_to_work > timedelta(): - days_to_work[day] = time_to_work - - extra_days_to_work = dict() - - try: - with open(f"{config_dir}/days-to-work", 'r') as extra_days_to_work_file: - for line in extra_days_to_work_file: - stripped_line = line.strip() - if stripped_line: - splitLine = stripped_line.split(' ') - if len(splitLine) == 2: - [hours, datestr] = splitLine - day = datetime.strptime(datestr, date_format).replace(tzinfo=tzlocal()).date() - extra_days_to_work[day] = timedelta(hours = float(hours)) - else: - extra_days_to_work[datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()).date()] = self.time_per_day - except IOError as e: - if e.errno != 2: - raise e - - - self.is_workday = self.now.date() in days_to_work or self.now.date() in extra_days_to_work - - self.time_worked = timedelta() - - if self.include_running: - self.running_entry = api.get_running_clock(self.now) - - if self.running_entry: - self.time_worked += self.running_entry - - if self.running_entry and self.include_running and self.force_day_to_work and not (self.now.date() in days_to_work or self.now.date() in extra_days_to_work): - extra_days_to_work[self.now.date()] = timedelta() - - self.time_to_work = sum([days_to_work[day] for day in days_to_work.keys() if day <= end_date.date()], timedelta()) - for day in [d for d in list(pull_forward) if d > end_date.date()]: - days_forward = set([d for d in days_to_work.keys() if d >= end_date.date() and d < day and (not d in pull_forward or d == end_date.date())]) - extra_days_forward = set([d for d in extra_days_to_work.keys() if d >= end_date.date() and d < day and (not d in pull_forward or d == end_date.date())]) - days_forward = days_forward.union(extra_days_forward) - - extra_day_time_left = timedelta() - for extra_day in extra_days_forward: - day_time = max(timedelta(), self.time_per_day - extra_days_to_work[extra_day]) - extra_day_time_left += day_time - extra_day_time = min(extra_day_time_left, pull_forward[day]) - time_forward = pull_forward[day] - extra_day_time - if extra_day_time_left > timedelta(): - for extra_day in extra_days_forward: - day_time = max(timedelta(), self.time_per_day - extra_days_to_work[extra_day]) - extra_days_to_work[extra_day] += extra_day_time * (day_time / extra_day_time_left) - - hours_per_day_forward = time_forward / len(days_forward) if len(days_forward) > 0 else timedelta() - days_forward.discard(end_date.date()) - - self.time_pulled_forward += time_forward - hours_per_day_forward * len(days_forward) - - if end_date.date() in extra_days_to_work: - self.time_pulled_forward += extra_days_to_work[end_date.date()] - - self.time_to_work += self.time_pulled_forward - - self.time_worked += api.get_billable_hours(start_date, self.now, rounding = config.getboolean('WORKTIME', 'rounding', fallback=True)) - -def worktime(**args): - worktime = Worktime(**args) - - def format_worktime(worktime): - def difference_string(difference): - total_minutes_difference = round(difference / timedelta(minutes = 1)) - (hours_difference, minutes_difference) = divmod(abs(total_minutes_difference), 60) - sign = '' if total_minutes_difference >= 0 else '-' - - difference_string = f"{sign}" - if hours_difference != 0: - difference_string += f"{hours_difference}h" - if hours_difference == 0 or minutes_difference != 0: - difference_string += f"{minutes_difference}m" - - return difference_string - - difference = worktime.time_to_work - worktime.time_worked - total_minutes_difference = 5 * ceil(difference / timedelta(minutes = 5)) - - if worktime.running_entry and abs(difference) < timedelta(days = 1) and (total_minutes_difference > 0 or abs(worktime.running_entry) >= abs(difference)) : - clockout_time = worktime.now + difference - clockout_time += (5 - clockout_time.minute % 5) * timedelta(minutes = 1) - clockout_time = clockout_time.replace(second = 0, microsecond = 0) - - if total_minutes_difference >= 0: - difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1)) - return f"{difference_string}/{clockout_time:%H:%M}" - else: - difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1)) - return f"{clockout_time:%H:%M}/{difference_string}" - else: - if worktime.running_entry: - difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1)) - indicator = '↓' if total_minutes_difference >= 0 else '↑' # '\u25b6' - - return f"{indicator}{difference_string}" - else: - difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1)) - if worktime.is_workday: - return difference_string - else: - return f"({difference_string})" - - if worktime.time_pulled_forward >= timedelta(minutes = 15): - worktime_no_pulled_forward = deepcopy(worktime) - worktime_no_pulled_forward.time_to_work -= worktime_no_pulled_forward.time_pulled_forward - worktime_no_pulled_forward.time_pulled_forward = timedelta() - - difference_string = format_worktime(worktime) - difference_string_no_pulled_forward = format_worktime(worktime_no_pulled_forward) - - print(f"{difference_string_no_pulled_forward}…{difference_string}") - else: - print(format_worktime(worktime)) - -def time_worked(now, **args): - then = now.replace(hour = 0, minute = 0, second = 0, microsecond = 0) - if now.time() == time(): - now = now + timedelta(days = 1) - - then = Worktime(**dict(args, now = then)) - now = Worktime(**dict(args, now = now)) - - worked = now.time_worked - then.time_worked - - if args['do_round']: - total_minutes_difference = 5 * ceil(worked / timedelta(minutes = 5)) - (hours_difference, minutes_difference) = divmod(abs(total_minutes_difference), 60) - sign = '' if total_minutes_difference >= 0 else '-' - - difference_string = f"{sign}" - if hours_difference != 0: - difference_string += f"{hours_difference}h" - if hours_difference == 0 or minutes_difference != 0: - difference_string += f"{minutes_difference}m" - - clockout_time = None - clockout_difference = None - if then.is_workday or now.is_workday: - target_time = max(then.time_per_day, now.time_per_day) if then.time_per_day and now.time_per_day else (then.time_per_day if then.time_per_day else now.time_per_day); - difference = target_time - worked - clockout_difference = 5 * ceil(difference / timedelta(minutes = 5)) - clockout_time = now.now + difference - clockout_time += (5 - clockout_time.minute % 5) * timedelta(minutes = 1) - clockout_time = clockout_time.replace(second = 0, microsecond = 0) - - if now.running_entry and clockout_time and clockout_difference >= 0: - print(f"{difference_string}/{clockout_time:%H:%M}") - else: - print(difference_string) - else: - print(worked) - -def diff(now, **args): - now = now.replace(hour = 0, minute = 0, second = 0, microsecond = 0) - then = now - timedelta.resolution - - then = Worktime(**dict(args, now = then, include_running = False)) - now = Worktime(**dict(args, now = now, include_running = False)) - - print(now.time_to_work - then.time_to_work) - -def holidays(year, **args): - config = Worktime.config() - date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d') - - table_data = [] - - holidays = Worktime.holidays(year) - for k, v in holidays.items(): - kstr = k.strftime(date_format) - table_data += [[kstr, v]] - print(tabulate(table_data, tablefmt="plain")) - -def leave(year, table, **args): - def_year = datetime.now(tzlocal()).year - worktime = Worktime(**dict(**args, end_datetime = datetime(year = (year if year else def_year) + 1, month = 1, day = 1, tzinfo=tzlocal()) - timedelta(microseconds=1))) - config = Worktime.config() - date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d') - leave_expires = config.get('WORKTIME', 'LeaveExpires', fallback=None) - if leave_expires: - leave_expires = datetime.strptime(leave_expires, '%m-%d').date() - - leave_budget = deepcopy(worktime.leave_budget) - year_leave_budget = deepcopy(worktime.leave_budget) if year else None - years = sorted(leave_budget.keys()) - for day in sorted(worktime.leave_days): - for iyear in years: - if day > leave_expires.replace(year = iyear + 1): - continue - if leave_budget[iyear] <= 0: - continue - - leave_budget[iyear] -= 1 - if year_leave_budget and day.year < year: - year_leave_budget[iyear] -= 1 - break - else: - print(f'Unaccounted leave: {day}', file=stderr) - - if table and year: - table_data = [] - leave_days = sorted([day for day in worktime.leave_days if day.year == year and worktime.would_be_workday(day)]) - - count = 0 - for _, group in groupby(enumerate(leave_days), lambda kv: kv[0] - worktime.ordinal_workday(kv[1])): - group = list(map(lambda kv: kv[1], group)) - - for day in group: - for iyear in years: - if day > leave_expires.replace(year = iyear + 1): - continue - if year_leave_budget[iyear] <= 0: - continue - - year_leave_budget[iyear] -= 1 - break - - next_count = count + len(group) - if len(group) > 1: - table_data.append([count, group[0].strftime('%m-%d') + '--' + group[-1].strftime('%m-%d'), len(group), sum(year_leave_budget.values())]) - else: - table_data.append([count, group[0].strftime('%m-%d'), len(group), sum(year_leave_budget.values())]) - count = next_count - print(tabulate(table_data, tablefmt="plain")) - elif table: - table_data = [] - for year, days in leave_budget.items(): - leave_days = sorted([day for day in worktime.leave_days if day.year == year]) - table_data += [[year, days, ','.join(map(lambda d: d.strftime('%m-%d'), leave_days))]] - print(tabulate(table_data, tablefmt="plain")) - else: - print(leave_budget[year if year else def_year]) - -def main(): - parser = argparse.ArgumentParser(prog = "worktime", description = 'Track worktime using toggl API') - parser.add_argument('--time', dest = 'now', metavar = 'TIME', type = lambda s: datetime.fromisoformat(s).replace(tzinfo=tzlocal()), help = 'Time to calculate status for (default: current time)', default = datetime.now(tzlocal())) - parser.add_argument('--no-running', dest = 'include_running', action = 'store_false') - parser.add_argument('--no-force-day-to-work', dest = 'force_day_to_work', action = 'store_false') - subparsers = parser.add_subparsers(help = 'Subcommands') - parser.set_defaults(cmd = worktime) - time_worked_parser = subparsers.add_parser('time_worked', aliases = ['time', 'worked', 'today']) - time_worked_parser.add_argument('--no-round', dest = 'do_round', action = 'store_false') - time_worked_parser.set_defaults(cmd = time_worked) - diff_parser = subparsers.add_parser('diff') - diff_parser.set_defaults(cmd = diff) - holidays_parser = subparsers.add_parser('holidays') - holidays_parser.add_argument('year', metavar = 'YEAR', type = int, help = 'Year to evaluate holidays for (default: current year)', default = datetime.now(tzlocal()).year, nargs='?') - holidays_parser.set_defaults(cmd = holidays) - leave_parser = subparsers.add_parser('leave') - leave_parser.add_argument('year', metavar = 'YEAR', type = int, help = 'Year to evaluate leave days for (default: current year)', default = None, nargs='?') - leave_parser.add_argument('--table', action = 'store_true') - leave_parser.set_defaults(cmd = leave) - args = parser.parse_args() - - args.cmd(**vars(args)) - -if __name__ == "__main__": - sys.exit(main()) diff --git a/overlays/worktime/worktime/__init__.py b/overlays/worktime/worktime/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/overlays/worktime/worktime/__main__.py b/overlays/worktime/worktime/__main__.py new file mode 100755 index 00000000..ed7880db --- /dev/null +++ b/overlays/worktime/worktime/__main__.py @@ -0,0 +1,617 @@ +import requests +from requests.exceptions import HTTPError +from requests.auth import HTTPBasicAuth +from datetime import * +from xdg import (BaseDirectory) +import configparser +from uritools import uricompose + +from dateutil.easter import * +from dateutil.tz import * +from dateutil.parser import isoparse + +from enum import Enum + +from math import (copysign, ceil, floor) + +import calendar + +import argparse + +from copy import deepcopy + +import sys +from sys import stderr + +from tabulate import tabulate + +from itertools import groupby +from functools import cache + +import backoff + + +class TogglAPISection(Enum): + TOGGL = '/api/v8' + REPORTS = '/reports/api/v2' + +class TogglAPIError(Exception): + def __init__(self, http_error, response): + self.http_error = http_error + self.response = response + + def __str__(self): + if not self.http_error is None: + return str(self.http_error) + else: + return self.response.text + +class TogglAPI(object): + def __init__(self, api_token, workspace_id, client_ids): + self._api_token = api_token + self._workspace_id = workspace_id + self._client_ids = set(map(int, client_ids.split(','))) if client_ids else None + + def _make_url(self, api=TogglAPISection.TOGGL, section=['time_entries', 'current'], params={}): + if api is TogglAPISection.REPORTS: + params.update({'user_agent': 'worktime', 'workspace_id': self._workspace_id}) + + api_path = api.value + section_path = '/'.join(section) + uri = uricompose(scheme='https', host='api.track.toggl.com', path=f"{api_path}/{section_path}", query=params) + + return uri + + def _query(self, url, method): + response = self._raw_query(url, method) + response.raise_for_status() + return response + + @backoff.on_predicate( + backoff.expo, + factor=0.1, max_value=2, + predicate=lambda r: r.status_code == 429, + max_time=10, + ) + def _raw_query(self, url, method): + headers = {'content-type': 'application/json'} + response = None + + if method == 'GET': + response = requests.get(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token')) + elif method == 'POST': + response = requests.post(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token')) + else: + raise ValueError(f"Undefined HTTP method “{method}”") + + return response + + def get_billable_hours(self, start_date, end_date=datetime.now(timezone.utc), rounding=False): + billable_acc = timedelta(milliseconds = 0) + step = timedelta(days = 365) + + for req_start in [start_date + x * step for x in range(0, ceil((end_date - start_date) / step))]: + req_end = end_date + if end_date > req_start + step: + req_end = datetime.combine((req_start + step).astimezone(timezone.utc).date(), time(tzinfo=timezone.utc)) + elif req_start > start_date: + req_start = datetime.combine(req_start.astimezone(timezone.utc).date(), time(tzinfo=timezone.utc)) + timedelta(days = 1) + + def get_report(client_ids = self._client_ids): + nonlocal req_start, req_end, rounding, self + + if client_ids is not None and not client_ids: + return timedelta(milliseconds = 0) + + params = { 'since': req_start.astimezone(timezone.utc).isoformat(), + 'until': req_end.astimezone(timezone.utc).isoformat(), + 'rounding': rounding, + 'billable': 'yes' + } + if client_ids is not None: + params |= { 'client_ids': ','.join(map(str, client_ids)) } + url = self._make_url(api = TogglAPISection.REPORTS, section = ['summary'], params = params) + r = self._query(url = url, method='GET') + if not r or not r.json(): + raise TogglAPIError(r) + res = timedelta(milliseconds=r.json()['total_billable']) if r.json()['total_billable'] else timedelta(milliseconds=0) + return res + + if 0 in self._client_ids: + url = self._make_url(api = TogglAPISection.TOGGL, section = ['workspaces', self._workspace_id, 'clients']) + r = self._query(url = url, method = 'GET') + if not r or not r.json(): + raise TogglAPIError(r) + + billable_acc += get_report(None) - get_report(set(map(lambda c: c['id'], r.json()))) + + billable_acc += get_report(self._client_ids - {0}) + + return billable_acc + + def get_running_clock(self, now=datetime.now(timezone.utc)): + url = self._make_url(api = TogglAPISection.TOGGL, section = ['time_entries', 'current']) + r = self._query(url = url, method='GET') + + if not r or not r.json(): + raise TogglAPIError(r) + + if not r.json()['data'] or not r.json()['data']['billable']: + return None + + if self._client_ids is not None: + if 'pid' in r.json()['data'] and r.json()['data']['pid']: + url = self._make_url(api = TogglAPISection.TOGGL, section = ['projects', str(r.json()['data']['pid'])]) + pr = self._query(url = url, method = 'GET') + if not pr or not pr.json(): + raise TogglAPIError(pr) + + if not pr.json()['data']: + return None + + if 'cid' in pr.json()['data'] and pr.json()['data']['cid']: + if pr.json()['data']['cid'] not in self._client_ids: + return None + elif 0 not in self._client_ids: + return None + elif 0 not in self._client_ids: + return None + + start = isoparse(r.json()['data']['start']) + + return now - start if start <= now else None + +class Worktime(object): + time_worked = timedelta() + running_entry = None + now = datetime.now(tzlocal()) + time_pulled_forward = timedelta() + is_workday = False + include_running = True + time_to_work = None + force_day_to_work = True + leave_days = set() + leave_budget = dict() + time_per_day = None + workdays = None + + @staticmethod + @cache + def holidays(year): + holidays = dict() + + y_easter = datetime.combine(easter(year), time(), tzinfo=tzlocal()) + + # Legal holidays in munich, bavaria + holidays[datetime(year, 1, 1, tzinfo=tzlocal()).date()] = 1 + holidays[datetime(year, 1, 6, tzinfo=tzlocal()).date()] = 1 + holidays[(y_easter+timedelta(days=-2)).date()] = 1 + holidays[(y_easter+timedelta(days=+1)).date()] = 1 + holidays[datetime(year, 5, 1, tzinfo=tzlocal()).date()] = 1 + holidays[(y_easter+timedelta(days=+39)).date()] = 1 + holidays[(y_easter+timedelta(days=+50)).date()] = 1 + holidays[(y_easter+timedelta(days=+60)).date()] = 1 + holidays[datetime(year, 8, 15, tzinfo=tzlocal()).date()] = 1 + holidays[datetime(year, 10, 3, tzinfo=tzlocal()).date()] = 1 + holidays[datetime(year, 11, 1, tzinfo=tzlocal()).date()] = 1 + holidays[datetime(year, 12, 25, tzinfo=tzlocal()).date()] = 1 + holidays[datetime(year, 12, 26, tzinfo=tzlocal()).date()] = 1 + + return holidays + + @staticmethod + def config(): + config = configparser.ConfigParser() + config_dir = BaseDirectory.load_first_config('worktime') + config.read(f"{config_dir}/worktime.ini") + return config + + def ordinal_workday(self, date): + start_date = datetime(date.year, 1, 1, tzinfo=tzlocal()).date() + return len([1 for offset in range(0, (date - start_date).days + 1) if self.would_be_workday(start_date + timedelta(days = offset))]) + + def would_be_workday(self, date): + return date.isoweekday() in self.workdays and date not in set(day for (day, val) in Worktime.holidays(date.year).items() if val >= 1) + + def __init__(self, start_datetime=None, end_datetime=None, now=None, include_running=True, force_day_to_work=True, **kwargs): + self.include_running = include_running + self.force_day_to_work = force_day_to_work + + if now: + self.now = now + + config = Worktime.config() + config_dir = BaseDirectory.load_first_config('worktime') + api = TogglAPI(api_token=config['TOGGL']['ApiToken'], workspace_id=config['TOGGL']['Workspace'], client_ids=config.get('TOGGL', 'ClientIds', fallback=None)) + date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d') + + start_date = start_datetime or datetime.strptime(config['WORKTIME']['StartDate'], date_format).replace(tzinfo=tzlocal()) + end_date = end_datetime or self.now + + try: + with open(f"{config_dir}/reset", 'r') as reset: + for line in reset: + stripped_line = line.strip() + reset_date = datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()) + + if reset_date > start_date and reset_date <= end_date: + start_date = reset_date + except IOError as e: + if e.errno != 2: + raise e + + + hours_per_week = float(config.get('WORKTIME', 'HoursPerWeek', fallback=40)) + self.workdays = set([int(d.strip()) for d in config.get('WORKTIME', 'Workdays', fallback='1,2,3,4,5').split(',')]) + self.time_per_day = timedelta(hours = hours_per_week) / len(self.workdays) + + holidays = dict() + + leave_per_year = int(config.get('WORKTIME', 'LeavePerYear', fallback=30)) + for year in range(start_date.year, end_date.year + 1): + holidays |= {k: v * self.time_per_day for k, v in Worktime.holidays(year).items()} + leave_frac = 1 + if date(year, 1, 1) < start_date.date(): + leave_frac = (date(year + 1, 1, 1) - start_date.date()) / (date(year + 1, 1, 1) - date(year, 1, 1)) + self.leave_budget |= {year: floor(leave_per_year * leave_frac)} + + try: + with open(f"{config_dir}/reset-leave", 'r') as excused: + for line in excused: + stripped_line = line.strip() + if stripped_line: + [datestr, count] = stripped_line.split(' ') + day = datetime.strptime(datestr, date_format).replace(tzinfo=tzlocal()).date() + if day != start_date.date(): + continue + + self.leave_budget[day.year] = (self.leave_budget[day.year] if day.year in self.leave_budget else 0) + int(count) + except IOError as e: + if e.errno != 2: + raise e + + + for excused_kind in {'excused', 'leave'}: + try: + with open(f"{config_dir}/{excused_kind}", 'r') as excused: + for line in excused: + stripped_line = line.strip() + if stripped_line: + splitLine = stripped_line.split(' ') + fromDay = toDay = None + def parse_datestr(datestr): + nonlocal fromDay, toDay + def parse_single(singlestr): + return datetime.strptime(singlestr, date_format).replace(tzinfo=tzlocal()).date() + if '--' in datestr: + [fromDay,toDay] = datestr.split('--') + fromDay = parse_single(fromDay) + toDay = parse_single(toDay) + else: + fromDay = toDay = parse_single(datestr) + time = self.time_per_day + if len(splitLine) == 2: + [hours, datestr] = splitLine + time = timedelta(hours = float(hours)) + parse_datestr(datestr) + else: + parse_datestr(stripped_line) + + for day in [fromDay + timedelta(days = x) for x in range(0, (toDay - fromDay).days + 1)]: + if end_date.date() < day or day < start_date.date(): + continue + + if excused_kind == 'leave' and self.would_be_workday(day): + self.leave_days.add(day) + holidays[day] = time + except IOError as e: + if e.errno != 2: + raise e + + pull_forward = dict() + + start_day = start_date.date() + end_day = end_date.date() + + try: + with open(f"{config_dir}/pull-forward", 'r') as excused: + for line in excused: + stripped_line = line.strip() + if stripped_line: + [hours, datestr] = stripped_line.split(' ') + constr = datestr.split(',') + for d in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1 + int(timedelta(hours = float(hours)).total_seconds() / 60 * (7 / len(self.workdays)) * 2))]: + for c in constr: + if c in calendar.day_abbr: + if not d.strftime('%a') == c: break + elif "--" in c: + [fromDay,toDay] = c.split('--') + if fromDay != "": + fromDay = datetime.strptime(fromDay, date_format).replace(tzinfo=tzlocal()).date() + if not fromDay <= d: break + if toDay != "": + toDay = datetime.strptime(toDay, date_format).replace(tzinfo=tzlocal()).date() + if not d <= toDay: break + else: + if not d == datetime.strptime(c, date_format).replace(tzinfo=tzlocal()).date(): break + else: + if d >= end_date.date(): + pull_forward[d] = min(timedelta(hours = float(hours)), self.time_per_day - (holidays[d] if d in holidays else timedelta())) + except IOError as e: + if e.errno != 2: + raise e + + days_to_work = dict() + + if pull_forward: + end_day = max(end_day, max(list(pull_forward))) + + for day in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1)]: + if day.isoweekday() in self.workdays: + time_to_work = self.time_per_day + if day in holidays.keys(): + time_to_work -= holidays[day] + if time_to_work > timedelta(): + days_to_work[day] = time_to_work + + extra_days_to_work = dict() + + try: + with open(f"{config_dir}/days-to-work", 'r') as extra_days_to_work_file: + for line in extra_days_to_work_file: + stripped_line = line.strip() + if stripped_line: + splitLine = stripped_line.split(' ') + if len(splitLine) == 2: + [hours, datestr] = splitLine + day = datetime.strptime(datestr, date_format).replace(tzinfo=tzlocal()).date() + extra_days_to_work[day] = timedelta(hours = float(hours)) + else: + extra_days_to_work[datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()).date()] = self.time_per_day + except IOError as e: + if e.errno != 2: + raise e + + + self.is_workday = self.now.date() in days_to_work or self.now.date() in extra_days_to_work + + self.time_worked = timedelta() + + if self.include_running: + self.running_entry = api.get_running_clock(self.now) + + if self.running_entry: + self.time_worked += self.running_entry + + if self.running_entry and self.include_running and self.force_day_to_work and not (self.now.date() in days_to_work or self.now.date() in extra_days_to_work): + extra_days_to_work[self.now.date()] = timedelta() + + self.time_to_work = sum([days_to_work[day] for day in days_to_work.keys() if day <= end_date.date()], timedelta()) + for day in [d for d in list(pull_forward) if d > end_date.date()]: + days_forward = set([d for d in days_to_work.keys() if d >= end_date.date() and d < day and (not d in pull_forward or d == end_date.date())]) + extra_days_forward = set([d for d in extra_days_to_work.keys() if d >= end_date.date() and d < day and (not d in pull_forward or d == end_date.date())]) + days_forward = days_forward.union(extra_days_forward) + + extra_day_time_left = timedelta() + for extra_day in extra_days_forward: + day_time = max(timedelta(), self.time_per_day - extra_days_to_work[extra_day]) + extra_day_time_left += day_time + extra_day_time = min(extra_day_time_left, pull_forward[day]) + time_forward = pull_forward[day] - extra_day_time + if extra_day_time_left > timedelta(): + for extra_day in extra_days_forward: + day_time = max(timedelta(), self.time_per_day - extra_days_to_work[extra_day]) + extra_days_to_work[extra_day] += extra_day_time * (day_time / extra_day_time_left) + + hours_per_day_forward = time_forward / len(days_forward) if len(days_forward) > 0 else timedelta() + days_forward.discard(end_date.date()) + + self.time_pulled_forward += time_forward - hours_per_day_forward * len(days_forward) + + if end_date.date() in extra_days_to_work: + self.time_pulled_forward += extra_days_to_work[end_date.date()] + + self.time_to_work += self.time_pulled_forward + + self.time_worked += api.get_billable_hours(start_date, self.now, rounding = config.getboolean('WORKTIME', 'rounding', fallback=True)) + +def worktime(**args): + worktime = Worktime(**args) + + def format_worktime(worktime): + def difference_string(difference): + total_minutes_difference = round(difference / timedelta(minutes = 1)) + (hours_difference, minutes_difference) = divmod(abs(total_minutes_difference), 60) + sign = '' if total_minutes_difference >= 0 else '-' + + difference_string = f"{sign}" + if hours_difference != 0: + difference_string += f"{hours_difference}h" + if hours_difference == 0 or minutes_difference != 0: + difference_string += f"{minutes_difference}m" + + return difference_string + + difference = worktime.time_to_work - worktime.time_worked + total_minutes_difference = 5 * ceil(difference / timedelta(minutes = 5)) + + if worktime.running_entry and abs(difference) < timedelta(days = 1) and (total_minutes_difference > 0 or abs(worktime.running_entry) >= abs(difference)) : + clockout_time = worktime.now + difference + clockout_time += (5 - clockout_time.minute % 5) * timedelta(minutes = 1) + clockout_time = clockout_time.replace(second = 0, microsecond = 0) + + if total_minutes_difference >= 0: + difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1)) + return f"{difference_string}/{clockout_time:%H:%M}" + else: + difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1)) + return f"{clockout_time:%H:%M}/{difference_string}" + else: + if worktime.running_entry: + difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1)) + indicator = '↓' if total_minutes_difference >= 0 else '↑' # '\u25b6' + + return f"{indicator}{difference_string}" + else: + difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1)) + if worktime.is_workday: + return difference_string + else: + return f"({difference_string})" + + if worktime.time_pulled_forward >= timedelta(minutes = 15): + worktime_no_pulled_forward = deepcopy(worktime) + worktime_no_pulled_forward.time_to_work -= worktime_no_pulled_forward.time_pulled_forward + worktime_no_pulled_forward.time_pulled_forward = timedelta() + + difference_string = format_worktime(worktime) + difference_string_no_pulled_forward = format_worktime(worktime_no_pulled_forward) + + print(f"{difference_string_no_pulled_forward}…{difference_string}") + else: + print(format_worktime(worktime)) + +def time_worked(now, **args): + then = now.replace(hour = 0, minute = 0, second = 0, microsecond = 0) + if now.time() == time(): + now = now + timedelta(days = 1) + + then = Worktime(**dict(args, now = then)) + now = Worktime(**dict(args, now = now)) + + worked = now.time_worked - then.time_worked + + if args['do_round']: + total_minutes_difference = 5 * ceil(worked / timedelta(minutes = 5)) + (hours_difference, minutes_difference) = divmod(abs(total_minutes_difference), 60) + sign = '' if total_minutes_difference >= 0 else '-' + + difference_string = f"{sign}" + if hours_difference != 0: + difference_string += f"{hours_difference}h" + if hours_difference == 0 or minutes_difference != 0: + difference_string += f"{minutes_difference}m" + + clockout_time = None + clockout_difference = None + if then.is_workday or now.is_workday: + target_time = max(then.time_per_day, now.time_per_day) if then.time_per_day and now.time_per_day else (then.time_per_day if then.time_per_day else now.time_per_day); + difference = target_time - worked + clockout_difference = 5 * ceil(difference / timedelta(minutes = 5)) + clockout_time = now.now + difference + clockout_time += (5 - clockout_time.minute % 5) * timedelta(minutes = 1) + clockout_time = clockout_time.replace(second = 0, microsecond = 0) + + if now.running_entry and clockout_time and clockout_difference >= 0: + print(f"{difference_string}/{clockout_time:%H:%M}") + else: + print(difference_string) + else: + print(worked) + +def diff(now, **args): + now = now.replace(hour = 0, minute = 0, second = 0, microsecond = 0) + then = now - timedelta.resolution + + then = Worktime(**dict(args, now = then, include_running = False)) + now = Worktime(**dict(args, now = now, include_running = False)) + + print(now.time_to_work - then.time_to_work) + +def holidays(year, **args): + config = Worktime.config() + date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d') + + table_data = [] + + holidays = Worktime.holidays(year) + for k, v in holidays.items(): + kstr = k.strftime(date_format) + table_data += [[kstr, v]] + print(tabulate(table_data, tablefmt="plain")) + +def leave(year, table, **args): + def_year = datetime.now(tzlocal()).year + worktime = Worktime(**dict(**args, end_datetime = datetime(year = (year if year else def_year) + 1, month = 1, day = 1, tzinfo=tzlocal()) - timedelta(microseconds=1))) + config = Worktime.config() + date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d') + leave_expires = config.get('WORKTIME', 'LeaveExpires', fallback=None) + if leave_expires: + leave_expires = datetime.strptime(leave_expires, '%m-%d').date() + + leave_budget = deepcopy(worktime.leave_budget) + year_leave_budget = deepcopy(worktime.leave_budget) if year else None + years = sorted(leave_budget.keys()) + for day in sorted(worktime.leave_days): + for iyear in years: + if day > leave_expires.replace(year = iyear + 1): + continue + if leave_budget[iyear] <= 0: + continue + + leave_budget[iyear] -= 1 + if year_leave_budget and day.year < year: + year_leave_budget[iyear] -= 1 + break + else: + print(f'Unaccounted leave: {day}', file=stderr) + + if table and year: + table_data = [] + leave_days = sorted([day for day in worktime.leave_days if day.year == year and worktime.would_be_workday(day)]) + + count = 0 + for _, group in groupby(enumerate(leave_days), lambda kv: kv[0] - worktime.ordinal_workday(kv[1])): + group = list(map(lambda kv: kv[1], group)) + + for day in group: + for iyear in years: + if day > leave_expires.replace(year = iyear + 1): + continue + if year_leave_budget[iyear] <= 0: + continue + + year_leave_budget[iyear] -= 1 + break + + next_count = count + len(group) + if len(group) > 1: + table_data.append([count, group[0].strftime('%m-%d') + '--' + group[-1].strftime('%m-%d'), len(group), sum(year_leave_budget.values())]) + else: + table_data.append([count, group[0].strftime('%m-%d'), len(group), sum(year_leave_budget.values())]) + count = next_count + print(tabulate(table_data, tablefmt="plain")) + elif table: + table_data = [] + for year, days in leave_budget.items(): + leave_days = sorted([day for day in worktime.leave_days if day.year == year]) + table_data += [[year, days, ','.join(map(lambda d: d.strftime('%m-%d'), leave_days))]] + print(tabulate(table_data, tablefmt="plain")) + else: + print(leave_budget[year if year else def_year]) + +def main(): + parser = argparse.ArgumentParser(prog = "worktime", description = 'Track worktime using toggl API') + parser.add_argument('--time', dest = 'now', metavar = 'TIME', type = lambda s: datetime.fromisoformat(s).replace(tzinfo=tzlocal()), help = 'Time to calculate status for (default: current time)', default = datetime.now(tzlocal())) + parser.add_argument('--no-running', dest = 'include_running', action = 'store_false') + parser.add_argument('--no-force-day-to-work', dest = 'force_day_to_work', action = 'store_false') + subparsers = parser.add_subparsers(help = 'Subcommands') + parser.set_defaults(cmd = worktime) + time_worked_parser = subparsers.add_parser('time_worked', aliases = ['time', 'worked', 'today']) + time_worked_parser.add_argument('--no-round', dest = 'do_round', action = 'store_false') + time_worked_parser.set_defaults(cmd = time_worked) + diff_parser = subparsers.add_parser('diff') + diff_parser.set_defaults(cmd = diff) + holidays_parser = subparsers.add_parser('holidays') + holidays_parser.add_argument('year', metavar = 'YEAR', type = int, help = 'Year to evaluate holidays for (default: current year)', default = datetime.now(tzlocal()).year, nargs='?') + holidays_parser.set_defaults(cmd = holidays) + leave_parser = subparsers.add_parser('leave') + leave_parser.add_argument('year', metavar = 'YEAR', type = int, help = 'Year to evaluate leave days for (default: current year)', default = None, nargs='?') + leave_parser.add_argument('--table', action = 'store_true') + leave_parser.set_defaults(cmd = leave) + args = parser.parse_args() + + args.cmd(**vars(args)) + +if __name__ == "__main__": + sys.exit(main()) -- cgit v1.2.3